Repo created
Some checks failed
build / build (push) Has been cancelled
build / test (push) Has been cancelled

This commit is contained in:
Fr4nz D13trich 2025-12-18 08:31:42 +01:00
commit 3c8e58604e
646 changed files with 69135 additions and 0 deletions

4
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,4 @@
buy_me_a_coffee: beemdevelopment
custom:
- "https://www.blockchain.com/btc/address/bc1q26kyxqjkc6tu477pzy0whagwhs4ypv93qls22n"
- "https://nanocrawler.cc/explorer/account/nano_1aegisc559b1x4p3839egnu579jkd4htpidy14eo9e31gzqmwuafypnj4q94"

89
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View file

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

6
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View file

@ -0,0 +1,6 @@
---
name: "Feature request"
about: "Suggest a new feature for this project"
labels: proposal
---

View file

@ -0,0 +1,63 @@
name: build
on: [pull_request, push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@v4
- name: Validate Gradle wrapper
uses: gradle/wrapper-validation-action@699bb18358f12c5b78b37bb0111d3a0e2276e0e2
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
- name: Build the app
run: ./gradlew build
- uses: actions/upload-artifact@v4
with:
name: apk
path: app/build/outputs/apk/debug/app-debug.apk
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Tests
uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d
with:
api-level: 31
arch: x86_64
profile: pixel_3a
heap-size: 512M
ram-size: 4096M
emulator-options: -memory 4096 -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
disk-size: 8G
script: |
mkdir -p artifacts/report
adb logcat -c
adb logcat -G 16M && adb logcat -g
./gradlew connectedCheck || touch tests_failing
adb logcat -d > artifacts/logcat.txt
cp -r app/build/reports/androidTests/connected/* artifacts/report/
if adb shell '[ -e /sdcard/Pictures/screenshots ]'; then adb pull /sdcard/Pictures/screenshots artifacts/; fi
test ! -f tests_failing
- uses: actions/upload-artifact@v4
if: always()
with:
name: instrumented-test-report
path: |
artifacts/*
if-no-files-found: ignore

42
.github/workflows/codeql-analysis.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: codeql
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '25 16 * * 2'
jobs:
analyze:
name: analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
if: github.event_name != 'schedule' || github.repository == 'beemdevelopment/Aegis'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Exclude paths
# The importers are excluded from analysis, because some of the apps Aegis
# can import from don't have such great crypto, which will cause false
# positive security alerts.
run: |
find app/src/main/java/com/beemdevelopment/aegis/importers ! \( -name AegisImporter.java -o -name "DatabaseImporter*" \) -type f -exec rm -f {} +
sed -i '/Importer.class/d' app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: java
- name: Build
run: ./gradlew assembleDebug
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

25
.github/workflows/crowdin.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: crowdin
on:
push:
branches:
- master
# run sequentially (per branch)
concurrency: "crowdin-upload-${{ github.ref }}"
jobs:
upload-sources:
runs-on: ubuntu-latest
if: github.repository == 'beemdevelopment/Aegis'
steps:
- uses: actions/checkout@v4
- name: Install crowdin-cli
run: |
wget https://github.com/crowdin/crowdin-cli/releases/download/4.6.1/crowdin-cli.zip
echo "7afd70de3a747ac631a5bad7866008163ae1d50c4606b5773f0b90a5481ffde2 crowdin-cli.zip" | sha256sum -c
unzip crowdin-cli.zip -d crowdin-cli
- name: Upload to Crowdin
env:
CROWDIN_PERSONAL_TOKEN: "${{ secrets.CROWDIN_TOKEN }}"
run: |
java -jar ./crowdin-cli/4.6.1/crowdin-cli.jar upload sources \
--no-progress \
--branch master

43
.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
# Built application files
*.apk
*.ap_
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
release/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# Intellij
*.iml
.idea/
# Keystore files
*.jks
crowdin.properties
.crowdin/config.yml

124
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,124 @@
# Contributing
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
translations of Aegis for lots of different languages. __Pull requests that
add/update a translation are no longer accepted.__ Crowdin is our single source
of truth for translations, to keep things easy to maintain.
The top 30 languages are available for translation. It's possible that yours is
not in that list. If that's the case, feel free to send us an email and we'll
add it.
## Pull requests
If you're planning on adding a new feature or making other large changes, please
discuss it with us first through [a
proposal](https://github.com/beemdevelopment/Aegis/issues/new?labels=proposal&template=feature.md)
on GitHub. Discussing your idea with us first ensures that everyone is on the
same page before you start working on your change. We don't like rejecting pull
requests.
## Bug reports
We use GitHub's issue tracker to track bugs. To make bug reports easier to
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
it! We're happy to help you if you get stuck along the way.
### Capturing a log with ADB
In some cases, we ask our users to obtain a debug log from their device. This is
typically only necessary if Aegis:
- Is unable to recover from an error and crashes.
- Only shows a generic error to the user, but writes a more detailed one to the
log.
Capturing a log with the Android Debug Bridge (ADB) allows us to see the stack
trace and the exception that occurred.
#### Preparation
Before you can capture a log, you first need to go through a one-time setup
process on your Android device and computer.
##### Prerequisites
- Your Android device.
- A computer with Windows, Mac or Linux.
- A USB cable to connect your Android device to your computer.
##### Setup
__On your Android device__:
1. Navigate to ``Settings -> About``, scroll down and start tapping on the build
number until developer options are enabled.
2. Navigate to ``Settings -> System -> Developer options`` and enable ``USB
debugging``.
These navigation steps may differ slightly across Android versions and ROMs.
__On your computer__:
3. Download and extract the SDK platform tools for Android:
https://developer.android.com/studio/releases/platform-tools.
4. Start your terminal emulator (If you're on Windows, start PowerShell) and
navigate to the folder where platform-tools was extracted.
5. Execute ``adb devices``.
__On your Android device__:
6. A prompt will appear. Select "Always allow from this computer" and accept the
connection.
#### Capturing a log
__On your Android device__:
1. Start Aegis.
__On your PC__:
2. Start your terminal emulator (If you're on Windows, start PowerShell) and
navigate to the folder where platform-tools was extracted.
3. Start a log capture by executing the following commands.
```
adb logcat -c
adb logcat > debug.log
```
The logcat command captures the full system log by default, which may expose
some sensitive information. While this information can sometimes help with
finding the root cause of the issue, it is not always necessary. To only
capture the log output of Aegis, replace the last logcat command with the
one below:
```sh
adb logcat --pid=$(adb shell pidof -s com.beemdevelopment.aegis) > debug.log
```
_If you are using a debug APK, replace ``com.beemdevelopment.aegis`` with
``com.beemdevelopment.aegis.debug``._
__On your Android device__:
4. Reproduce the issue.
__On your PC__:
5. Stop the log capture with Ctrl+C.
6. Attach the ``debug.log`` file to your issue on GitHub.

109
FAQ.md Normal file
View file

@ -0,0 +1,109 @@
# FAQ
## General
### How can I contribute?
There are lots of ways! Please refer to our [contributing
guide](https://github.com/beemdevelopment/Aegis/blob/master/CONTRIBUTING.md).
### Why is the latest version not on F-Droid yet?
We don't release new versions of Aegis on F-Droid ourselves. Once we've released
a new version on GitHub, F-Droid will usually kick off their automatic build
process a day later and publish the app to their repository a couple of days
afterwards. It can sometimes take up to a week for a new version to appear on
F-Droid.
### Can you port Aegis to iOS/Windows/MacOS/Browser Extension?
We don't have plans to port Aegis to other platforms.
### Can you add support for Autofill?
On Android, only one app can be active in the Autofill slot at a time, and since
this is typically occupied by the password manager, we don't see much value in
adding support for this feature in Aegis.
### What is the difference between exporting and backing up?
Exporting is done manually and backups are done automatically. The format of the
vault file is exactly the same for both.
## Security
### I can no longer use biometrics to unlock the app. What should I do?
If you could previously unlock Aegis with biometrics, but suddenly can't do so
anymore, this is probably caused by a change made to the security settings of
your device. The app will tell you when this happened in most cases. To resolve
this, unlock the app with your password, disable biometric unlock in the
settings of Aegis and re-enable it.
### Why does Aegis keep prompting me for my password, even though I have enabled biometric authentication?
You're probably encountering the password reminder. Try entering your password
to unlock the vault once. After that, Aegis will prompt for biometrics by
default again until it's time for another password reminder.
Since forgetting your password will result in loss of access to the contents of
the vault, __we do NOT recommend disabling the password reminder__.
### Aegis uses SHA1 for most/all of my tokens. Isn't that insecure?
The hash algorithm is imposed by the service you're setting up 2FA for (e.g.
Google, Facebook, GitHub, etc). There is nothing we can do about that. If we
were to change this on Aegis' end, the tokens would stop working. Furthermore,
when using SHA1 in an HMAC calculation, the currently known issues in SHA1 are
not of concern.
### Why doesn't Aegis support biometric unlock for my device, even though it works with other apps?
The reason for this is pretty technical. In short, since you're not entering
your password when using biometric unlock, Aegis needs some other way to decrypt
the vault. For this purpose, we generate and use a key in the Android Keystore,
telling it to only allow us to use that key if the user authenticates using
their biometrics first. Some devices have buggy implementations of this feature,
resulting in the error displayed to you by Aegis in an error dialog.
If biometrics works with other apps, but not with Aegis, that means those other
apps probably perform a weaker form of biometric authentication.
## Backups
### How can I back up my Aegis vault to the cloud automatically?
Aegis can only automatically back up to the cloud if the app of your cloud
provider is installed on your device and fully participates in the Android
Storage Access Framework. Aegis doesn't have access to the internet and we don't
have plans to change this, so adding support for specific cloud providers in the
app is not possible.
Cloud providers currently known to be supported:
- Nextcloud
Another common setup is to configure Aegis to back up to a folder on local
storage of your device and then have a separate app (like
[Syncthing](https://syncthing.net/)) sync that folder anywhere you want.
## Encrypted Backups
### Why do I not get prompted to enter an encryption password when exporting?
Aegis uses the same password you have configured to encrypt your vault as the
password which is used when exporting and importing your vault; so when prompted,
you will enter that when importing your vault.
## Importing
### When importing from Authenticator Plus, an error is shown claiming that Accounts.txt is missing
Make sure you supply an Authenticator Plus export file obtained through
__Settings -> Backup & Restore -> Export as Text and HTML__. The ``.db`` format
is not supported.
If it still doesn't work, please report the issue to us. As a temporary
workaround, you can try extracting the ZIP archive on a computer, recreating it
without a password and then importing that into Aegis. Another option is
extracting the ZIP archive on a computer and importing the resulting
Accounts.txt file into Aegis with the "Plain text" import option.

674
LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

172
README.md Normal file
View file

@ -0,0 +1,172 @@
<img align="left" width="80" height="80" src="metadata/en-US/images/icon.png"
alt="App icon">
# Aegis Authenticator
<br>
[![Build](https://github.com/beemdevelopment/Aegis/actions/workflows/build-app-workflow.yaml/badge.svg)](https://github.com/beemdevelopment/Aegis/actions/workflows/build-app-workflow.yaml?query=branch%3Amaster) [![Crowdin](https://badges.crowdin.net/aegis-authenticator/localized.svg)](https://crowdin.com/project/aegis-authenticator) [![Donate](https://img.shields.io/badge/donate-buy%20us%20a%20beer-%23FF813F)](https://www.buymeacoffee.com/beemdevelopment) [![Matrix](https://img.shields.io/matrix/aegis:matrix.org?color=blue)](https://matrix.to/#/#aegis:matrix.org)
**Aegis Authenticator** is a free, secure and open source 2FA app for Android.
It aims to provide a secure authenticator for your online services, while also
including some features missing in existing authenticator apps, like proper
encryption and backups. Aegis supports HOTP and TOTP, making it compatible with
thousands of services.
For a list of frequently asked questions, please check out [the FAQ](FAQ.md).
The security design of the app and the vault format is described in detail in
[this document](docs/vault.md).
## Features
- Free and open source
- Secure
- The vault is encrypted (AES-256-GCM), and can be unlocked with:
- Password (scrypt)
- Biometrics (Android Keystore)
- Screen capture prevention
- Tap to reveal
- Compatible with Google Authenticator
- Supports industry standard algorithms:
[HOTP](https://tools.ietf.org/html/rfc4226) and
[TOTP](https://tools.ietf.org/html/rfc6238)
- Lots of ways to add new entries
- Scan a QR code or an image of one
- Enter details manually
- Import from other authenticator apps: 2FAS Authenticator, Authenticator
Plus, Authy, andOTP, FreeOTP, FreeOTP+, Google Authenticator, Microsoft
Authenticator, Plain text, Steam, TOTP Authenticator and WinAuth (root
access is required for some of these)
- Organization
- Alphabetic/custom sorting
- Custom or automatically generated icons
- Group entries together
- Advanced entry editing
- Search by name/issuer
- Material design with multiple themes: Light, Dark, AMOLED
- Export (plaintext or encrypted)
- Automatic backups of the vault to a location of your choosing
## Screenshots
[<img width=200 alt="Screenshot 1"
src="metadata/en-US/images/phoneScreenshots/screenshot1.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot1.png?raw=true)
[<img width=200 alt="Screenshot 2"
src="metadata/en-US/images/phoneScreenshots/screenshot2.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot2.png?raw=true)
[<img width=200 alt="Screenshot 3"
src="metadata/en-US/images/phoneScreenshots/screenshot3.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot3.png?raw=true)
[<img width=200 alt="Screenshot 4"
src="metadata/en-US/images/phoneScreenshots/screenshot4.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot4.png?raw=true)
[<img width=200 alt="Screenshot 5"
src="metadata/en-US/images/phoneScreenshots/screenshot5.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot5.png?raw=true)
[<img width=200 alt="Screenshot 6"
src="metadata/en-US/images/phoneScreenshots/screenshot6.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot6.png?raw=true)
[<img width=200 alt="Screenshot 7"
src="metadata/en-US/images/phoneScreenshots/screenshot7.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot7.png?raw=true)
[<img width=200 alt="Screenshot 8"
src="metadata/en-US/images/phoneScreenshots/screenshot8.png?raw=true">](metadata/en-US/images/phoneScreenshots/screenshot8.png?raw=true)
## Downloads
Aegis is available on the Google Play Store and on F-Droid.
[<img height=80 alt="Get it on Google Play"
src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
/>](http://play.google.com/store/apps/details?id=com.beemdevelopment.aegis)
[<img height="80" alt="Get it on F-Droid"
src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
/>](https://f-droid.org/app/com.beemdevelopment.aegis)
### Verification
APK releases on Google Play and GitHub are signed using the same key. They can
be verified using
[apksigner](https://developer.android.com/studio/command-line/apksigner.html#options-verify):
```
apksigner verify --print-certs --verbose aegis.apk
```
The output should look like:
```
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
```
The certificate fingerprints should correspond to the ones listed below:
```
Owner: CN=Beem Development
Issuer: CN=Beem Development
Serial number: 172380c
Valid from: Sat Feb 09 14:05:49 CET 2019 until: Wed Feb 03 14:05:49 CET 2044
Certificate fingerprints:
MD5: AA:EE:86:DB:C7:B8:88:9F:1F:C9:D0:7A:EC:37:36:32
SHA1: 59:FB:63:B7:1F:CE:95:74:6C:EB:1E:1A:CB:2C:2E:45:E5:FF:13:50
SHA256: C6:DB:80:A8:E1:4E:52:30:C1:DE:84:15:EF:82:0D:13:DC:90:1D:8F:E3:3C:F3:AC:B5:7B:68:62:D8:58:A8:23
```
### Icon packs
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
documentation](docs/iconpacks.md).
- [aegis-icons](https://github.com/aegis-icons/aegis-icons)
Unofficial monochrome-styled 2FA icons.
[<img width=500 alt="aegis-icons preview"
src="metadata/en-US/images/iconPacks/aegis-icons.png">](https://github.com/aegis-icons/aegis-icons)
- [delta-aegis-icons](https://github.com/Delta-Icons/aegis-icons)
Delta version of the unofficial monochrome-styled 2FA icon pack aegis-icons.
[<img width=500 alt="delta-icons preview"
src="metadata/en-US/images/iconPacks/delta-icons.png">](https://github.com/Delta-Icons/aegis-icons)
- [aegis-simple-icons](https://github.com/alexbakker/aegis-simple-icons) \*
This project periodically generates an icon pack for Aegis based on [Simple
Icons](https://simpleicons.org/).
[<img width=500 alt="aegis-simple-icons preview"
src="metadata/en-US/images/iconPacks/aegis-simple-icons.png">](https://github.com/alexbakker/aegis-simple-icons)
- [aegis-simple-icons-outlined](https://github.com/michaelschattgen/aegis-simple-icons-outlined) \*
This is a variant on the aegis-simple-icons pack where the icons contain no solid background and just the outlines are being used.
[<img width=500 alt="aegis-simple-icons-outlined preview"
src="metadata/en-US/images/iconPacks/aegis-simple-icons-outlined.png">](https://github.com/michaelschattgen/aegis-simple-icons-outlined)
\* The icons are automatically generated, so
not all of them are as high quality as the ones you'll find in
[aegis-icons](https://github.com/aegis-icons/aegis-icons).
## Contributing
Looking to contribute to Aegis? That's great! There are a couple of ways to help
out. Translations, bug reports and pull requests are all greatly appreciated.
Please refer to our [contributing guidelines](CONTRIBUTING.md) to get started.
Swing by our Matrix room to interact with other contributors:
[#aegis:matrix.org](https://matrix.to/#/#aegis:matrix.org).
## License
This project is licensed under the GNU General Public License v3.0. See the
[LICENSE](LICENSE) file for details.
A couple of libraries vendored in Aegis' repository are licensed under a
different license:
- [TextDrawable](app/src/main/java/com/amulyakhare/textdrawable)
- [TrustedIntents](app/src/main/java/info/guardianproject/trustedintents)

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

225
app/build.gradle Normal file
View file

@ -0,0 +1,225 @@
apply plugin: 'com.android.application'
apply plugin: 'com.google.protobuf'
apply plugin: 'dagger.hilt.android.plugin'
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
def getCmdOutput = { cmd ->
def stdout = new ByteArrayOutputStream()
exec {
commandLine cmd
standardOutput = stdout
}
return stdout.toString().trim()
}
def getGitHash = { -> return getCmdOutput(["git", "rev-parse", "--short", "HEAD"]) }
def getGitBranch = { -> return getCmdOutput(["git", "rev-parse", "--abbrev-ref", "HEAD"]) }
def packageName = "com.beemdevelopment.aegis"
def fileProviderAuthority = "${packageName}.fileprovider"
def fileProviderAuthorityDebug = "${packageName}.debug.fileprovider"
android {
compileSdk 35
namespace packageName
defaultConfig {
applicationId "${packageName}"
minSdkVersion 23
targetSdkVersion 35
versionCode 80
versionName "3.4.1"
multiDexEnabled true
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
buildConfigField "String", "GIT_BRANCH", "\"${getGitBranch()}\""
buildConfigField "java.util.concurrent.atomic.AtomicBoolean", "TEST", "new java.util.concurrent.atomic.AtomicBoolean(false)"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas"]
}
}
testInstrumentationRunner "com.beemdevelopment.aegis.AegisTestRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
unitTests {
all {
maxHeapSize "3g"
ignoreFailures false
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
showExceptions true
exceptionFormat "full"
showCauses true
showStackTraces true
}
}
includeAndroidResources true
}
}
buildTypes {
debug {
applicationIdSuffix ".debug"
manifestPlaceholders = [
title: "AegisDev",
iconName: "ic_launcher_debug",
fileProviderAuthority: "${fileProviderAuthorityDebug}"
]
buildConfigField("String", "FILE_PROVIDER_AUTHORITY", "\"${fileProviderAuthorityDebug}\"")
resValue "bool", "pref_secure_screen_default", "false"
}
release {
manifestPlaceholders = [
title: "Aegis",
iconName: "ic_launcher",
fileProviderAuthority: "${fileProviderAuthority}"
]
buildConfigField("String", "FILE_PROVIDER_AUTHORITY", "\"${fileProviderAuthority}\"")
resValue "bool", "pref_secure_screen_default", "true"
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// Required to make the APK reproducible
aaptOptions {
cruncherEnabled = false
}
defaultConfig {
vectorDrawables.generatedDensities = []
}
packagingOptions {
// R8 doesn't remove these resources, so exclude them manually. This reduces APK size by 4MB.
resources {
excludes += [
'/org/bouncycastle/pqc/**/*.properties',
'META-INF/versions/9/OSGI-INF/MANIFEST.MF'
]
}
}
compileOptions {
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled true
}
lint {
abortOnError true
checkDependencies true
}
buildFeatures {
buildConfig true
}
}
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.25.1'
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
}
}
}
}
aboutLibraries {
// Tasks for aboutLibraries are not run automatically to keep the build reproducible
// To update manually: ./gradlew app:exportLibraryDefinitions -PaboutLibraries.exportPath=src/main/res/raw
prettyPrint = true
configPath = "app/config"
fetchRemoteFunding = false
registerAndroidTasks = false
exclusionPatterns = [~"javax.annotation.*"]
duplicationMode = com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
}
dependencies {
def cameraxVersion = '1.4.2'
def glideVersion = '4.16.0'
def guavaVersion = '33.4.8'
def hiltVersion = '2.56.2'
def junitVersion = '4.13.2'
def libsuVersion = '6.0.0'
def roomVersion = '2.7.1'
annotationProcessor 'androidx.annotation:annotation:1.9.1'
annotationProcessor "androidx.room:room-compiler:$roomVersion"
annotationProcessor "com.google.dagger:hilt-compiler:$hiltVersion"
annotationProcessor "com.github.bumptech.glide:compiler:${glideVersion}"
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.activity:activity:1.10.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.camera:camera-camera2:$cameraxVersion"
implementation "androidx.camera:camera-lifecycle:$cameraxVersion"
implementation "androidx.camera:camera-view:$cameraxVersion"
implementation 'androidx.core:core:1.16.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.lifecycle:lifecycle-process:2.9.0'
implementation "androidx.preference:preference:1.2.1"
implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation "androidx.room:room-runtime:$roomVersion"
implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation "com.google.dagger:hilt-android:$hiltVersion"
implementation 'com.github.avito-tech:krop:0.52'
implementation "com.github.bumptech.glide:annotations:${glideVersion}"
implementation "com.github.bumptech.glide:glide:${glideVersion}"
implementation("com.github.bumptech.glide:recyclerview-integration:${glideVersion}") {
transitive = false
}
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.12.0'
implementation 'com.google.protobuf:protobuf-javalite:4.31.0'
implementation 'com.google.zxing:core:3.5.3'
implementation('com.mikepenz:aboutlibraries:11.2.3') {
exclude group: 'com.mikepenz', module: 'aboutlibraries-core'
}
implementation 'com.mikepenz:aboutlibraries-core-android:11.2.3'
implementation 'com.nulab-inc:zxcvbn:1.9.0'
implementation 'net.lingala.zip4j:zip4j:2.11.5'
implementation 'org.bouncycastle:bcprov-jdk18on:1.80'
implementation 'org.simpleflatmapper:sfm-csv:8.2.3'
androidTestAnnotationProcessor "com.google.dagger:hilt-android-compiler:$hiltVersion"
androidTestImplementation "com.google.dagger:hilt-android-testing:$hiltVersion"
androidTestImplementation 'androidx.test:core:1.6.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.6.1'
androidTestImplementation "junit:junit:${junitVersion}"
androidTestUtil 'androidx.test:orchestrator:1.5.1'
testImplementation 'androidx.test:core:1.6.1'
testImplementation "com.google.guava:guava:${guavaVersion}-jre"
testImplementation "junit:junit:${junitVersion}"
testImplementation 'org.json:json:20250517'
testImplementation 'org.robolectric:robolectric:4.14.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}

View file

@ -0,0 +1,6 @@
{
"uniqueId": "com.github.avito-tech:krop",
"licenses": [
"MIT"
]
}

View file

@ -0,0 +1,6 @@
{
"uniqueId": "com.github.topjohnwu.libsu:.*::regex",
"licenses": [
"Apache-2.0"
]
}

View file

@ -0,0 +1,15 @@
{
"uniqueId": "com.amulyakhare:com.amulyakhare.textdrawable",
"funding": [
],
"developers": [
],
"artifactVersion": "1.0.1",
"description": "This light-weight library provides images with letter/text like the Gmail app. It extends the Drawable class thus can be used with existing/custom/network ImageView classes. Also included is a fluent interface for creating drawables and a customizable ColorGenerator.",
"name": "textdrawable",
"licenses": [
"MIT"
]
}

View file

@ -0,0 +1,23 @@
{
"uniqueId": "info.guardianproject.trustedintents:trustedintents",
"funding": [
],
"developers": [
{
"name": "Guardian Project"
}
],
"artifactVersion": "0.2",
"description": "TrustedIntents is a library for flexible trusted interactions between Android apps. It is modeled after Android's `signature` protection level for permissions. The key difference is that the framework allows the trusted signature to be set, rather than requiring to match the current app's signature.",
"scm": {
"connection": "scm:https://github.com/guardianproject/TrustedIntents.git",
"url": "scm:https://github.com/guardianproject/TrustedIntents",
"developerConnection": "scm:git@github.com:guardianproject/TrustedIntents.git"
},
"name": "TrustedIntents",
"website": "https://guardianproject.info/code/trustedintents",
"licenses": [
"3ca920d1875f7ad7ab04a2a331958577"
]
}

View file

@ -0,0 +1,5 @@
{
"hash": "3ca920d1875f7ad7ab04a2a331958577",
"url": "https://github.com/guardianproject/TrustedIntents/blob/master/LICENSE.txt",
"name": "LGPLv2.1"
}

15
app/lint.xml Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="MissingTranslation" severity="ignore" />
<issue id="MissingQuantity" severity="ignore" />
<issue id="InvalidPackage">
<ignore regexp="X509LDAPCertStoreSpi" />
</issue>
<issue id="NotificationPermission">
<ignore regexp="com.bumptech.glide.request.target.NotificationTarget" />
</issue>
<issue id="UnusedResources" severity="error">
<ignore path="res/raw/aboutlibraries.json" />
<ignore regexp="res/mipmap.*/ic_launcher_debug.*.png" />
</issue>
</lint>

10
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,10 @@
-keepattributes LineNumberTable,SourceFile
-renamesourcefileattribute SourceFile
-dontobfuscate
-keepclasseswithmembers public class androidx.recyclerview.widget.RecyclerView { *; }
-keep class com.beemdevelopment.aegis.ui.fragments.preferences.*
-keep class com.beemdevelopment.aegis.importers.** { *; }
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-dontwarn javax.naming.**

View file

@ -0,0 +1,52 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "392278bdb797d013cb2ada67a3b1cc60",
"entities": [
{
"tableName": "audit_logs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_type` TEXT NOT NULL, `reference` TEXT, `timestamp` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "_eventType",
"columnName": "event_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "_reference",
"columnName": "reference",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "_timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '392278bdb797d013cb2ada67a3b1cc60')"
]
}
}

View file

@ -0,0 +1,203 @@
package com.beemdevelopment.aegis;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.matcher.BoundedMatcher;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.GrantPermissionRule;
import com.beemdevelopment.aegis.crypto.CryptoUtils;
import com.beemdevelopment.aegis.crypto.SCryptParameters;
import com.beemdevelopment.aegis.otp.OtpInfo;
import com.beemdevelopment.aegis.ui.views.EntryHolder;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
import com.beemdevelopment.aegis.vault.VaultManager;
import com.beemdevelopment.aegis.vault.VaultRepository;
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
import com.beemdevelopment.aegis.vault.slots.SlotException;
import com.beemdevelopment.aegis.vectors.VaultEntries;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.junit.Before;
import org.junit.Rule;
import java.lang.reflect.InvocationTargetException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.inject.Inject;
import dagger.hilt.android.testing.HiltAndroidRule;
public abstract class AegisTest {
public static final String VAULT_PASSWORD = "test";
public static final String VAULT_PASSWORD_CHANGED = "test2";
public static final String VAULT_BACKUP_PASSWORD = "something";
public static final String VAULT_BACKUP_PASSWORD_CHANGED = "something2";
@Rule
public HiltAndroidRule hiltRule = new HiltAndroidRule(this);
@Rule
public final GrantPermissionRule permRule = getGrantPermissionRule();
@Inject
protected VaultManager _vaultManager;
@Inject
protected Preferences _prefs;
@Before
public void init() {
hiltRule.inject();
}
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 AegisApplicationBase getApp() {
return (AegisApplicationBase) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
}
protected VaultRepository initEncryptedVault() {
VaultFileCredentials creds = generateCredentials();
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 = _vaultManager.initNew(creds);
} catch (VaultRepositoryException e) {
throw new RuntimeException(e);
}
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;
}
protected VaultFileCredentials generateCredentials() {
PasswordSlot slot = new PasswordSlot();
byte[] salt = CryptoUtils.generateSalt();
SCryptParameters scryptParams = new SCryptParameters(
CryptoUtils.CRYPTO_SCRYPT_N,
CryptoUtils.CRYPTO_SCRYPT_r,
CryptoUtils.CRYPTO_SCRYPT_p,
salt
);
VaultFileCredentials creds = new VaultFileCredentials();
try {
SecretKey key = slot.deriveKey(VAULT_PASSWORD.toCharArray(), scryptParams);
slot.setKey(creds.getKey(), CryptoUtils.createEncryptCipher(key));
} catch (NoSuchAlgorithmException
| InvalidKeyException
| InvalidAlgorithmParameterException
| NoSuchPaddingException
| SlotException e) {
throw new RuntimeException(e);
}
creds.getSlots().add(slot);
return creds;
}
protected static <T extends OtpInfo> VaultEntry generateEntry(Class<T> type, String name, String issuer) {
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 {
info = type.getConstructor(byte[].class).newInstance(secret);
} catch (IllegalAccessException | InstantiationException | InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
return new VaultEntry(info, name, issuer);
}
// source: https://stackoverflow.com/a/30338665
protected static ViewAction clickChildViewWithId(final int id) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return null;
}
@Override
public String getDescription() {
return "Click on a child view with specified id.";
}
@Override
public void perform(UiController uiController, View view) {
View v = view.findViewById(id);
v.performClick();
}
};
}
@NonNull
protected static Matcher<RecyclerView.ViewHolder> withOtpType(Class<? extends OtpInfo> otpClass) {
return new BoundedMatcher<RecyclerView.ViewHolder, EntryHolder>(EntryHolder.class) {
@Override
public boolean matchesSafely(EntryHolder holder) {
return holder != null
&& holder.getEntry() != null
&& holder.getEntry().getInfo().getClass().equals(otpClass);
}
@Override
public void describeTo(Description description) {
description.appendText(String.format("with otp type '%s'", otpClass.getSimpleName()));
}
};
}
}

View file

@ -0,0 +1,7 @@
package com.beemdevelopment.aegis;
import dagger.hilt.android.testing.CustomTestApplication;
@CustomTestApplication(AegisApplicationBase.class)
public interface AegisTestApplication {
}

View file

@ -0,0 +1,40 @@
package com.beemdevelopment.aegis;
import android.app.Application;
import android.app.Instrumentation;
import android.content.Context;
import androidx.preference.PreferenceManager;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.runner.AndroidJUnitRunner;
import com.beemdevelopment.aegis.util.IOUtils;
public class AegisTestRunner extends AndroidJUnitRunner {
static {
BuildConfig.TEST.set(true);
}
@Override
public Application newApplication(ClassLoader cl, String name, Context context)
throws ClassNotFoundException, IllegalAccessException, InstantiationException {
return Instrumentation.newApplication(AegisTestApplication_Application.class, context);
}
@Override
public void callApplicationOnCreate(Application app) {
Context context = app.getApplicationContext();
// clear internal storage so that there is no vault file
IOUtils.clearDirectory(context.getFilesDir(), false);
// clear preferences so that the intro is started from MainActivity
ApplicationProvider.getApplicationContext().getFilesDir();
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.clear()
.apply();
super.callApplicationOnCreate(app);
}
}

View file

@ -0,0 +1,431 @@
package com.beemdevelopment.aegis;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.pressBack;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.intent.Intents.intending;
import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.net.Uri;
import androidx.annotation.Nullable;
import androidx.test.espresso.contrib.RecyclerViewActions;
import androidx.test.espresso.intent.Intents;
import androidx.test.espresso.matcher.RootMatchers;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;
import com.beemdevelopment.aegis.crypto.CryptoUtils;
import com.beemdevelopment.aegis.crypto.MasterKey;
import com.beemdevelopment.aegis.encoding.Hex;
import com.beemdevelopment.aegis.importers.DatabaseImporter;
import com.beemdevelopment.aegis.importers.DatabaseImporterException;
import com.beemdevelopment.aegis.importers.GoogleAuthUriImporter;
import com.beemdevelopment.aegis.otp.OtpInfoException;
import com.beemdevelopment.aegis.rules.ScreenshotTestRule;
import com.beemdevelopment.aegis.ui.PreferencesActivity;
import com.beemdevelopment.aegis.util.IOUtils;
import com.beemdevelopment.aegis.vault.VaultBackupManager;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultFile;
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
import com.beemdevelopment.aegis.vault.VaultFileException;
import com.beemdevelopment.aegis.vault.VaultRepository;
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
import com.beemdevelopment.aegis.vault.slots.SlotException;
import com.beemdevelopment.aegis.vault.slots.SlotIntegrityException;
import com.beemdevelopment.aegis.vault.slots.SlotList;
import com.beemdevelopment.aegis.vectors.VaultEntries;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.RuleChain;
import org.junit.rules.TestRule;
import org.junit.runner.RunWith;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import dagger.hilt.android.testing.HiltAndroidTest;
@RunWith(AndroidJUnit4.class)
@HiltAndroidTest
@SmallTest
public class BackupExportTest extends AegisTest {
private final ActivityScenarioRule<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());
File file = doExport();
checkHtmlExport(file);
}
@Test
public void testEncryptedVaultExportHtml() {
initEncryptedVault();
openExportDialog();
onView(withId(R.id.checkbox_export_encrypt)).perform(click());
onView(withId(R.id.dropdown_export_format)).perform(click());
onView(withText(R.string.export_format_html)).inRoot(RootMatchers.isPlatformPopup()).perform(click());
onView(withId(android.R.id.button1)).perform(click());
onView(withId(R.id.checkbox_accept)).perform(click());
File file = doExport();
checkHtmlExport(file);
}
@Test
public void testSeparateExportPassword() {
initEncryptedVault();
setSeparateBackupExportPassword();
openExportDialog();
File file = doExport();
readVault(file, VAULT_BACKUP_PASSWORD);
}
@Test
public void testChangeBackupPassword() throws SlotIntegrityException {
initEncryptedVault();
setSeparateBackupExportPassword();
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click()));
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_backup_password_change_title)), click()));
onView(withId(R.id.text_password)).perform(typeText(VAULT_BACKUP_PASSWORD_CHANGED), closeSoftKeyboard());
onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_BACKUP_PASSWORD_CHANGED), closeSoftKeyboard());
onView(withId(android.R.id.button1)).perform(click());
onView(isRoot()).perform(pressBack());
VaultFileCredentials creds = _vaultManager.getVault().getCredentials();
assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1);
assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1);
for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) {
verifyPasswordSlotChange(creds, slot, VAULT_BACKUP_PASSWORD, VAULT_BACKUP_PASSWORD_CHANGED);
}
for (PasswordSlot slot : creds.getSlots().findRegularPasswordSlots()) {
decryptPasswordSlot(slot, VAULT_PASSWORD);
}
openExportDialog();
File file = doExport();
readVault(file, VAULT_BACKUP_PASSWORD_CHANGED);
}
@Test
public void testChangePasswordHavingBackupPassword() throws SlotIntegrityException {
initEncryptedVault();
setSeparateBackupExportPassword();
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click()));
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_set_password_title)), click()));
onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD_CHANGED), closeSoftKeyboard());
onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD_CHANGED), closeSoftKeyboard());
onView(withId(android.R.id.button1)).perform(click());
onView(isRoot()).perform(pressBack());
VaultFileCredentials creds = _vaultManager.getVault().getCredentials();
assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1);
assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1);
for (PasswordSlot slot : creds.getSlots().findRegularPasswordSlots()) {
verifyPasswordSlotChange(creds, slot, VAULT_PASSWORD, VAULT_PASSWORD_CHANGED);
}
for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) {
decryptPasswordSlot(slot, VAULT_BACKUP_PASSWORD);
}
openExportDialog();
File file = doExport();
readVault(file, VAULT_BACKUP_PASSWORD);
}
private void setSeparateBackupExportPassword() {
VaultFileCredentials creds = _vaultManager.getVault().getCredentials();
assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1);
assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 0);
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click()));
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_backup_password_title)), click()));
onView(withId(R.id.text_password)).perform(typeText(VAULT_BACKUP_PASSWORD), closeSoftKeyboard());
onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_BACKUP_PASSWORD), closeSoftKeyboard());
onView(withId(android.R.id.button1)).perform(click());
onView(isRoot()).perform(pressBack());
creds = _vaultManager.getVault().getCredentials();
assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1);
assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1);
for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) {
verifyPasswordSlotChange(creds, slot, VAULT_PASSWORD, VAULT_BACKUP_PASSWORD);
}
}
private void verifyPasswordSlotChange(VaultFileCredentials creds, PasswordSlot slot, String oldPassword, String newPassword) {
assertThrows(SlotIntegrityException.class, () -> decryptPasswordSlot(slot, oldPassword));
MasterKey masterKey;
try {
masterKey = decryptPasswordSlot(slot, newPassword);
} catch (SlotIntegrityException e) {
throw new RuntimeException("Unable to decrypt password slot", e);
}
assertArrayEquals(creds.getKey().getBytes(), masterKey.getBytes());
}
private File doExport() {
File file = getExportFileUri();
Intent resultData = new Intent();
resultData.setData(Uri.fromFile(file));
Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData);
intending(not(isInternal())).respondWith(result);
onView(withId(android.R.id.button1)).perform(click());
return file;
}
private void openExportDialog() {
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_import_export_title)), click()));
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_export_title)), click()));
}
private MasterKey decryptPasswordSlot(PasswordSlot slot, String password) throws SlotIntegrityException {
SecretKey derivedKey = slot.deriveKey(password.toCharArray());
try {
Cipher cipher = slot.createDecryptCipher(derivedKey);
return slot.getKey(cipher);
} catch (SlotException e) {
throw new RuntimeException("Unable to decrypt password slot", e);
}
}
private File getExportFileUri() {
String dirName = Hex.encode(CryptoUtils.generateRandomBytes(8));
File dir = new File(getInstrumentation().getTargetContext().getExternalCacheDir(), String.format("export-%s", dirName));
if (!dir.mkdirs()) {
throw new RuntimeException(String.format("Unable to create export directory: %s", dir));
}
VaultBackupManager.FileInfo fileInfo = new VaultBackupManager.FileInfo(VaultRepository.FILENAME_PREFIX_EXPORT);
return new File(dir, fileInfo.toString());
}
private VaultRepository readVault(File file, @Nullable String password) {
VaultRepository repo;
try (InputStream inStream = new FileInputStream(file)) {
byte[] bytes = IOUtils.readAll(inStream);
VaultFile vaultFile = VaultFile.fromBytes(bytes);
VaultFileCredentials creds = null;
if (password != null) {
SlotList slots = vaultFile.getHeader().getSlots();
for (PasswordSlot slot : slots.findAll(PasswordSlot.class)) {
SecretKey derivedKey = slot.deriveKey(password.toCharArray());
Cipher cipher = slot.createDecryptCipher(derivedKey);
MasterKey masterKey = slot.getKey(cipher);
creds = new VaultFileCredentials(masterKey, slots);
break;
}
}
repo = VaultRepository.fromFile(getInstrumentation().getContext(), vaultFile, creds);
} catch (SlotException | SlotIntegrityException | VaultRepositoryException | VaultFileException | IOException e) {
throw new RuntimeException("Unable to read back vault file", e);
}
checkReadEntries(repo.getEntries());
return repo;
}
private void readTxtExport(File file) {
GoogleAuthUriImporter importer = new GoogleAuthUriImporter(getInstrumentation().getContext());
Collection<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 checkHtmlExport(File file) {
try (InputStream inStream = new FileInputStream(file)) {
Reader inReader = new InputStreamReader(inStream, StandardCharsets.UTF_8);
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
XmlPullParser parser = factory.newPullParser();
parser.setInput(inReader);
while (parser.getEventType() != XmlPullParser.START_TAG) {
parser.next();
}
if (!parser.getName().toLowerCase(Locale.ROOT).equals("html")) {
throw new RuntimeException("not an html document!");
}
while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
parser.next();
}
} catch (IOException | XmlPullParserException e) {
throw new RuntimeException("Unable to read html export file", e);
}
}
private void checkReadEntries(Collection<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++;
}
}
}

View file

@ -0,0 +1,68 @@
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;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
import com.beemdevelopment.aegis.otp.TotpInfo;
import com.beemdevelopment.aegis.ui.MainActivity;
import com.beemdevelopment.aegis.vault.VaultEntry;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import dagger.hilt.android.testing.HiltAndroidTest;
@RunWith(AndroidJUnit4.class)
@HiltAndroidTest
@LargeTest
public class DeepLinkTest extends AegisTest {
@Before
public void before() {
initEmptyEncryptedVault();
}
@Test
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) _vaultManager.getVault().getEntries().toArray()[0];
assertTrue(createdEntry.equivalates(entry));
}
@Test
public void testDeepLinkIntent_Empty() {
launch(null);
}
@Test
public void testDeepLinkIntent_Bad() {
launch(Uri.parse("otpauth://bad"));
onView(withId(android.R.id.button1)).perform(click());
}
@SuppressWarnings("deprecation")
private void launch(Uri uri) {
Intent intent = new Intent(getApp(), MainActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.setData(uri);
// we need to use the deprecated ActivityTestRule class because of https://github.com/android/android-test/issues/143
ActivityTestRule<MainActivity> rule = new ActivityTestRule<>(MainActivity.class);
rule.launchActivity(intent);
}
}

View file

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

View file

@ -0,0 +1,228 @@
package com.beemdevelopment.aegis;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.intent.Intents.intending;
import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
import static junit.framework.TestCase.assertFalse;
import static junit.framework.TestCase.assertNull;
import static junit.framework.TestCase.assertTrue;
import static org.hamcrest.Matchers.not;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.net.Uri;
import androidx.test.espresso.IdlingRegistry;
import androidx.test.espresso.IdlingResource;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.intent.Intents;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.viewpager2.widget.ViewPager2;
import com.beemdevelopment.aegis.rules.ScreenshotTestRule;
import com.beemdevelopment.aegis.ui.IntroActivity;
import com.beemdevelopment.aegis.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 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 testIntro_None() {
assertFalse(_prefs.isIntroDone());
ViewInteraction next = onView(withId(R.id.btnNext));
ViewInteraction prev = onView(withId(R.id.btnPrevious));
prev.check(matches(not(isDisplayed())));
next.perform(click());
onView(withId(R.id.rb_none)).perform(click());
prev.perform(click());
prev.check(matches(not(isDisplayed())));
next.perform(click());
next.perform(click());
prev.check(matches(not(isDisplayed())));
next.perform(click());
VaultRepository vault = _vaultManager.getVault();
assertFalse(vault.isEncryptionEnabled());
assertNull(vault.getCredentials());
assertTrue(_prefs.isIntroDone());
}
@Test
public void testIntro_Password() {
assertFalse(_prefs.isIntroDone());
ViewInteraction next = onView(withId(R.id.btnNext));
ViewInteraction prev = onView(withId(R.id.btnPrevious));
prev.check(matches(not(isDisplayed())));
next.perform(click());
onView(withId(R.id.rb_password)).perform(click());
prev.perform(click());
prev.check(matches(not(isDisplayed())));
next.perform(click());
next.perform(click());
onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard());
onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD + "1"), closeSoftKeyboard());
next.perform(click());
onView(withId(R.id.text_password_confirm)).perform(replaceText(VAULT_PASSWORD), closeSoftKeyboard());
prev.perform(click());
prev.perform(click());
prev.check(matches(not(isDisplayed())));
next.perform(click());
next.perform(click());
next.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());
}
@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;
}
}
}

View file

@ -0,0 +1,236 @@
package com.beemdevelopment.aegis;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu;
import static androidx.test.espresso.action.ViewActions.clearText;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.action.ViewActions.pressBack;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static junit.framework.TestCase.assertFalse;
import static junit.framework.TestCase.assertNull;
import static junit.framework.TestCase.assertTrue;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import androidx.annotation.IdRes;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.contrib.RecyclerViewActions;
import androidx.test.espresso.matcher.RootMatchers;
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.encoding.Hex;
import com.beemdevelopment.aegis.otp.HotpInfo;
import com.beemdevelopment.aegis.otp.MotpInfo;
import com.beemdevelopment.aegis.otp.SteamInfo;
import com.beemdevelopment.aegis.otp.TotpInfo;
import com.beemdevelopment.aegis.otp.YandexInfo;
import com.beemdevelopment.aegis.rules.ScreenshotTestRule;
import com.beemdevelopment.aegis.ui.MainActivity;
import com.beemdevelopment.aegis.ui.views.EntryAdapter;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultRepository;
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.RuleChain;
import org.junit.rules.TestRule;
import org.junit.runner.RunWith;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import 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 TestRule testRule = RuleChain.outerRule(_activityRule).around(new ScreenshotTestRule());
@Test
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(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());
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(YandexInfo.class, "Ivan", "Yandex", 16),
generateEntry(MotpInfo.class, "Jimmy McGill", "PfSense", 16)
);
for (VaultEntry entry : entries) {
addEntry(entry);
}
List<VaultEntry> realEntries = new ArrayList<>(vault.getEntries());
for (int i = 0; i < realEntries.size(); i++) {
String message = String.format("%s != %s", realEntries.get(i).toJson().toString(), entries.get(i).toJson().toString());
assertTrue(message, realEntries.get(i).equivalates(entries.get(i)));
}
for (int i = 0; i < 10; i++) {
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnHolderItem(withOtpType(HotpInfo.class), clickChildViewWithId(R.id.buttonRefresh)));
}
AtomicBoolean isErrorCardShown = new AtomicBoolean(false);
_activityRule.getScenario().onActivity(activity -> {
isErrorCardShown.set(((EntryAdapter)((RecyclerView) activity.findViewById(R.id.rvKeyProfiles)).getAdapter()).isErrorCardShown());
});
int entryPosOffset = isErrorCardShown.get() ? 1 : 0;
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 0, longClick()));
onView(withId(R.id.action_copy)).perform(click());
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 1, longClick()));
onView(withId(R.id.action_edit)).perform(click());
onView(withId(R.id.text_name)).perform(clearText(), typeText("Bob"), closeSoftKeyboard());
onView(withId(R.id.text_group)).perform(click());
onView(withId(R.id.addGroup)).inRoot(RootMatchers.isDialog()).perform(click());
onView(withId(R.id.text_input)).perform(typeText(_groupName), closeSoftKeyboard());
onView(withId(android.R.id.button1)).perform(click());
onView(withText(R.string.save)).perform(click());
onView(isRoot()).perform(pressBack());
onView(withId(android.R.id.button1)).perform(click());
changeSort(R.string.sort_alphabetically_name);
changeSort(R.string.sort_alphabetically_name_reverse);
changeSort(R.string.sort_alphabetically);
changeSort(R.string.sort_alphabetically_reverse);
changeSort(R.string.sort_custom);
changeGroupFilter(_groupName);
changeGroupFilter(null);
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 2, longClick()));
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 3, click()));
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 4, click()));
onView(withId(R.id.action_share_qr)).perform(click());
onView(withId(R.id.btnNext)).perform(click()).perform(click()).perform(click());
onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(entryPosOffset + 0, longClick()));
onView(allOf(isDescendantOfA(withClassName(containsString("ActionBarContextView"))), withClassName(containsString("OverflowMenuButton")))).perform(click());
onView(withText(R.string.action_delete)).perform(click());
onView(withId(android.R.id.button1)).perform(click());
openContextualActionModeOverflowMenu();
onView(withText(R.string.lock)).perform(click());
onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard());
onView(withId(R.id.button_decrypt)).perform(click());
vault = _vaultManager.getVault();
openContextualActionModeOverflowMenu();
onView(withText(R.string.action_settings)).perform(click());
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.actionOnItemAtPosition(1, click()));
onView(withId(android.R.id.button1)).perform(click());
assertFalse(vault.isEncryptionEnabled());
assertNull(vault.getCredentials());
onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItemAtPosition(1, click()));
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());
assertTrue(vault.isEncryptionEnabled());
assertTrue(vault.getCredentials().getSlots().has(PasswordSlot.class));
}
private void changeSort(@IdRes int resId) {
onView(withId(R.id.action_sort)).perform(click());
onView(withText(resId)).perform(click());
}
private void changeGroupFilter(String text) {
if (text == null) {
onView(allOf(withText(R.string.no_group), isDescendantOfA(withId(R.id.groupChipGroup)))).perform(click());
} else {
onView(allOf(withText(text), isDescendantOfA(withId(R.id.groupChipGroup)))).perform(click());
}
}
private void addEntry(VaultEntry entry) {
onView(withId(R.id.fab)).perform(click());
onView(withId(R.id.fab_enter)).perform(click());
onView(withId(R.id.accordian_header)).perform(scrollTo(), click());
onView(withId(R.id.text_name)).perform(typeText(entry.getName()), closeSoftKeyboard());
onView(withId(R.id.text_issuer)).perform(typeText(entry.getIssuer()), closeSoftKeyboard());
if (entry.getInfo().getClass() != TotpInfo.class) {
String otpType;
if (entry.getInfo() instanceof HotpInfo) {
otpType = "HOTP";
} else if (entry.getInfo() instanceof SteamInfo) {
otpType = "Steam";
} else if (entry.getInfo() instanceof YandexInfo) {
otpType = "Yandex";
} else if (entry.getInfo() instanceof MotpInfo) {
otpType = "MOTP";
} else if (entry.getInfo() instanceof TotpInfo) {
otpType = "TOTP";
} else {
throw new RuntimeException(String.format("Unexpected entry type: %s", entry.getInfo().getClass().getSimpleName()));
}
onView(withId(R.id.dropdown_type)).perform(scrollTo(), click());
onView(withText(otpType)).inRoot(RootMatchers.isPlatformPopup()).perform(click());
}
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());
}
}

View file

@ -0,0 +1,58 @@
package com.beemdevelopment.aegis;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;
import androidx.test.rule.ActivityTestRule;
import com.beemdevelopment.aegis.ui.PanicResponderActivity;
import com.beemdevelopment.aegis.vault.VaultRepository;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import dagger.hilt.android.testing.HiltAndroidTest;
@RunWith(AndroidJUnit4.class)
@HiltAndroidTest
@SmallTest
public class PanicTriggerTest extends AegisTest {
@Before
public void before() {
initEncryptedVault();
}
@Test
public void testPanicTriggerDisabled() {
assertFalse(_prefs.isPanicTriggerEnabled());
assertTrue(_vaultManager.isVaultLoaded());
launchPanic();
assertTrue(_vaultManager.isVaultLoaded());
_vaultManager.getVault();
assertTrue(VaultRepository.fileExists(getApp()));
}
@Test
public void testPanicTriggerEnabled() {
_prefs.setIsPanicTriggerEnabled(true);
assertTrue(_prefs.isPanicTriggerEnabled());
assertTrue(_vaultManager.isVaultLoaded());
launchPanic();
assertFalse(_vaultManager.isVaultLoaded());
assertThrows(IllegalStateException.class, () -> _vaultManager.getVault());
assertFalse(VaultRepository.fileExists(getApp()));
}
private void launchPanic() {
Intent intent = new Intent(PanicResponderActivity.PANIC_TRIGGER_ACTION);
// we need to use the deprecated ActivityTestRule class because of https://github.com/android/android-test/issues/143
ActivityTestRule<PanicResponderActivity> rule = new ActivityTestRule<>(PanicResponderActivity.class);
rule.launchActivity(intent);
}
}

View file

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

View file

@ -0,0 +1,43 @@
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;
import com.beemdevelopment.aegis.AegisTest;
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
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 VaultRepositoryTest extends AegisTest {
@Before
public void before() {
initEncryptedVault();
}
@Test
public void testToggleEncryption() throws VaultRepositoryException {
VaultRepository vault = _vaultManager.getVault();
_vaultManager.disableEncryption();
assertFalse(vault.isEncryptionEnabled());
assertNull(vault.getCredentials());
VaultFileCredentials creds = generateCredentials();
_vaultManager.enableEncryption(creds);
assertTrue(vault.isEncryptionEnabled());
assertNotNull(vault.getCredentials());
assertEquals(vault.getCredentials().getSlots().findAll(PasswordSlot.class).size(), 1);
}
}

View file

@ -0,0 +1 @@
../../../../../../test/java/com/beemdevelopment/aegis/vectors/VaultEntries.java

View file

@ -0,0 +1 @@
../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_encrypted.json

View file

@ -0,0 +1 @@
../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_plain.json

View file

@ -0,0 +1,166 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- 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" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<application
android:name=".AegisApplication"
android:allowBackup="true"
android:fullBackupOnly="true"
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:largeHeap="true"
android:theme="@style/Theme.Aegis.Launch"
tools:targetApi="tiramisu">
<activity android:name=".ui.TransferEntriesActivity"
android:label="@string/title_activity_transfer" />
<activity
android:name=".ui.AboutActivity"
android:label="@string/title_activity_about" />
<activity
android:name=".ui.ImportEntriesActivity"
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" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="otpauth" />
</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
android:name=".ui.ScannerActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:label="@string/title_activity_scan_qr"
android:screenOrientation="portrait" />
<activity
android:name=".ui.EditEntryActivity"
android:label="@string/title_activity_edit_entry" />
<activity
android:name=".ui.IntroActivity"
android:screenOrientation="portrait" />
<activity
android:name=".ui.AuthActivity" />
<activity
android:name=".ui.PreferencesActivity"
android:label="@string/title_activity_preferences" />
<activity
android:name=".ui.GroupManagerActivity"
android:label="@string/title_activity_manage_groups" />
<activity android:name=".ui.AssignIconsActivity"
android:label="@string/title_activity_assign_icons"/>
<activity android:name=".ui.LicensesActivity"
android:label="@string/title_activity_licenses"/>
<activity
android:name=".ui.PanicResponderActivity"
android:exported="true"
android:launchMode="singleInstance"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="info.guardianproject.panic.action.TRIGGER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</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"
android:authorities="${fileProviderAuthority}"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths">
</meta-data>
</provider>
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
</application>
<queries>
<package android:name="com.stratumauth.app" />
<package android:name="com.authy.authy" />
<package android:name="org.fedorahosted.freeotp" />
<package android:name="org.liberty.android.freeotpplus" />
<package android:name="com.google.android.apps.authenticator2" />
<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.messenger" />
</queries>
</manifest>

1
app/src/main/assets/LICENSE Symbolic link
View file

@ -0,0 +1 @@
../../../../LICENSE

View file

@ -0,0 +1,549 @@
<html>
<head>
<style type="text/css">
* {
word-wrap: break-word;
}
body {
background-color: %1$s;
color: %2$s;
}
ul {
list-style-position: inside;
padding: 0;
padding-left: 5px;
margin-bottom: 5px;
}
li {
padding-bottom: 8px;
list-style-position: outside;
margin-left: 1em;
}
h4 {
margin-top: 0px;
margin-bottom: 0px;
}
h3 {
margin-bottom: 7px;
padding-top: 7px;
}
</style>
</head>
<body>
<div></div>
<h3>Version 3.4.1</h3>
<h4>New</h4>
<ul>
<li>Support for importing from Proton Authenticator</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>The autofill service would show a prompt to save the PIN as a password</li>
</ul>
<h3>Version 3.4</h3>
<h4>New</h4>
<ul>
<li>Haptic feedback when an entry is about to expire</li>
<li>Brightness increase is now toggleable in the entry transfer view</li>
<li>Filter on multiple groups simultaneously</li>
<li>Color contrast on hidden codes has been improved</li>
<li>Prompt before the user is about to save an entry with a duplicate name/issuer combination</li>
<li>New languages: Estonian, Korean, Malayalam, Norwegian (Bokmål) and Serbian</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>A crash could occur if an entry with period 7 exists and code expiry indication is enabled</li>
<li>The Portuguese (Brazilian) locale was used even if Portuguese was configured</li>
<li>FreeOTP import would fail if the algorithm or digits field was not specified for an entry</li>
<li>The divider between entries would be missing in certain filter configurations</li>
<li>The snackbar in try entry importing view could obstruct the name of an entry</li>
</ul>
<h4>Miscellaneous</h4>
<ul>
<li>Android 6 or newer is now required the run the app</li>
</ul>
<h3>Version 3.3.4</h3>
<h4>Fixes</h4>
<ul>
<li>Icons are now resized to 512x512 to reduce the size of the vault file and to reduce the chance of encountering out of memory conditions</li>
</ul>
<h3>Version 3.3.3</h3>
<h4>Fixes</h4>
<ul>
<li>Some users ran into out of memory conditions due to large icons in their vault file. We've introduced a temporary measure that should help in most cases, but we'll follow up with a more comprehensive fix soon.</li>
<li>Window insets were not always applied correctly, causing parts of the UI to appear off-screen</li>
<li>The 2FAS importer did not tolerate spaces for secrets and was not always able to extract the issuer</li>
</ul>
<h3>Version 3.3.2</h3>
<h4>New</h4>
<ul>
<li>Find entries by searching in multiple fields simultaneously</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Entries would not actually be added to the Aegis vault in some cases when importing from Google Authenticator export QR codes</li>
<li>The lock button was sometimes shown for unencrypted vaults</li>
<li>The sort category menu item did not always reflect the current sorting</li>
<li>The next code was not always easy to read because its color had low contrast with the background</li>
<li>Entry selection was not cancelled when changing the group filter</li>
</ul>
<h3>Version 3.3.1</h3>
<h4>Fixes</h4>
<ul>
<li>Codes were not shown in case the tiles view mode was combined with hidden account names</li>
</ul>
<h3>Version 3.3</h3>
<h4>New</h4>
<ul>
<li>Significant improvements to group filtering
<ul>
<li>Groups can now be filtered on straight from the main view instead of through a dialog</li>
<li>Ability to assign multiple entries to a group in one go</li>
<li>Support for reordering groups</li>
</ul>
</li>
<li>Codes now change color when they're about to expire</li>
<li>Option to show the next code ahead of time</li>
<li>Support for backing up to a single file (This enables support for more cloud providers, such as Google Drive)</li>
<li>Various minor improvements to make QR code exports easier to scan</li>
<li>Support for importing from Ente Auth</li>
<li>Support for importing FreeOTP 2 backups</li>
<li>Updated translations</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>QR codes exported for Google Authenticator could not be scanned on iOS</li>
<li>The code would be copied after a single tap in case "Tap to reveal" and "Copy tokens to the clipboard" were enabled simultaneously</li>
<li>Various other minor UI, stability and performance improvements</li>
</ul>
<h3>Version 3.2</h3>
<h4>New</h4>
<ul>
<li>The ability to add a single entry to multiple groups</li>
<li>Option to keep an infinite number of backups</li>
<li>Option to customize which fields to search for in entries</li>
<li>Allow hiding entry names in the tiled view mode</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>With "Tap to reveal" enabled, the size of the shown dots would not be consistent with the size of the code digits, on some devices</li>
<li>After importing a backup, the UI would in some cases incorrectly claim that biometric unlock is enabled</li>
<li>The export dialog was not fully visible on some devices</li>
<li>Various other minor UI, stability and performance improvements</li>
</ul>
<h3>Version 3.1.1</h3>
<h4>Fixes</h4>
<p>
A recent Android Pixel update introduced a bug causing Aegis to sometimes show a black screen after unlocking the vault.
We have reported this issue to the Google Issue Tracker (<a href="https://issuetracker.google.com/issues/352963108">link</a>) and
are awaiting a response from Google. In the meantime, we have implemented a workaround that eliminates this bug.
</p>
<ul>
<li>Group filter now gets applied properly upon unlocking the vault</li>
<li>Advanced entry settings now gets shown correctly</li>
<li>Keyboard when searching for entries now gets hidden when the user starts scrolling through the list</li>
</ul>
<h3>Version 3.1</h3>
<h4>New</h4>
<ul>
<li>A new audit log has been added to check all important events that occurred in your vault</li>
<li>Added the ability to rename groups</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Group selection will now be remembered again upon launch</li>
<li>Various UI improvements</li>
<li>Stability fixes</li>
</ul>
<h3>Version 3.0.1</h3>
<h4>New</h4>
<ul>
<li>Support for importing from the new Battle.net app</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Visual glitches when AMOLED theme was used on old Android versions</li>
<li>Minor UI improvements</li>
</ul>
<h3>Version 3.0</h3>
<h4>New</h4>
<ul>
<li>Material 3 (and Material You)</li>
<li>Automatic assignment of icons to entries</li>
<li>Ability to select all entries in one go</li>
<li>Support for importing 2FAS schema v4 backups</li>
<li>Sort entries based on the last time they were used</li>
<li>Some clarifications related to importing and backup permission errors</li>
<li>Preparations for the ability to assign a single entry to multiple groups</li>
<li>Performance improvements when scrolling through an entry list with lots of icons</li>
<li>A new look for the third-party licenses list</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Directly importing from Authy using root would fail</li>
<li>Minor glitches related to animation duration scale settings</li>
<li>Various stability improvements</li>
</ul>
<h3>Version 2.2.2</h3>
<h4>New</h4>
<ul>
<li>An optional name field for icon packs to bypass filename character restrictions</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>The Authenticator Pro importer only supported the legacy backup format</li>
<li>A crash could occur in the tile service</li>
</ul>
<h3>Version 2.2.1</h3>
<h4>New</h4>
<ul>
<li>Ability to automatically skip potential duplicates when importing entries</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Biometrics button on the unlock screen was unresponsive</li>
</ul>
<h3>Version 2.2</h3>
<h4>New</h4>
<ul>
<li>Authenticator Pro encrypted import support</li>
<li>Ability to change account name position</li>
<li>A new dialog explaining how our password reminder works</li>
<li>Ability to change copy behavior</li>
<li>Ability to only show account names when necessary</li>
<li>New view mode: Tiles/Grid</li>
<li>Added translation: Dutch (Frysian)</li>
<li>Updated translations</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Deleting an entry while a search filter is active now shows the correct state</li>
<li>Aegis now fully respects system animation settings</li>
</ul>
<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>
<li>Support for importing 2FAS Authenticator's new backup format</li>
</ul>
<h3>Version 2.0.2</h3>
<h4>New</h4>
<ul>
<li>Add a note field to entries</li>
<li>An option to pause code updating of highlighted entries</li>
<li>New translation: Lithuanian</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Minor UI and stability improvements</li>
<li>The Microsoft Authenticator importer did not accept spaces and dashes in secrets</li>
</ul>
<h3>Version 2.0.1</h3>
<h4>New</h4>
<ul>
<li>Support for sorting on most used tokens</li>
<li>Some minor UX and stability improvements</li>
<li>New translation: Vietnamese</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>QR code information was decoded incorrectly in some cases if the app was set to a certain language (Turkish, for example)</li>
</ul>
<h3>Version 2.0</h3>
<h4>New</h4>
<ul>
<li>Support for icon packs</li>
<li>Support for participation in Android's backup system (Google Drive, Seedvault)</li>
<li>UI refresh (switched to the Material Components theme)</li>
<li>Bottom sheet with chips to filter on groups</li>
<li>Support for importing from 2FAS Authenticator</li>
<li>Search in account names by default (and remove the setting)</li>
<li>Replaced the FAB with a bottom sheet dialog</li>
<li>Reorganization of settings into separate categories</li>
<li>Ability to 'share' images of QR codes to scan in Aegis</li>
<li>Option to save the current group filter</li>
<li>New translations for Bulgarian, Danish, Latvian, Swedish and Ukranian</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>The QR code scanner had trouble detecting QR codes on some devices due to low resolution image capture</li>
<li>The app would vanish from the recent apps list after locking</li>
<li>When importing from Nextcloud, Aegis would report that the file could not be found.</li>
<li>The biometrics prompt would not appear on some devices</li>
<li>The app would lock when selecting a file/icon on certain devices and configurations</li>
<li>There were multiple layout issues on small screen devices</li>
<li>Various other usability, performance and stability improvements</li>
</ul>
<h3>Version 1.4.2</h3>
<h4>Fixes</h4>
<ul>
<li>The app would crash if DocumentsUI is not present on the device</li>
<li>The app would close when selecting an icon if auto lock on minimize was enabled</li>
<li>Importing from Authy was flaky for entries that have an icon</li>
<li>The dark theme was not properly applied to the QR code scanner view</li>
<li>The app would crash on plain text export on some devices</li>
<li>Importing from Authenticator Plus stopped working</li>
</ul>
<h3>Version 1.4.1</h3>
<h4>Fixes</h4>
<ul>
<li>Scanning QR codes stopped working on certain devices (primarily OnePlus)</li>
</ul>
<h3>Version 1.4</h3>
<h4>New</h4>
<ul>
<li>Optionally delete the vault if a panic trigger is received from Ripple</li>
<li>More customizable auto-lock</li>
<li>More flexible export options
<ul>
<li>Share mechanism</li>
<li>Offer to encrypt even if this feature is disabled in the app</li>
<li>Export to a Google Authenticator URI file</li>
</ul>
</li>
<li>Perform exports/backups on a background thread (automatic backups now work with Nextcloud)</li>
<li>Color improvements to the dark theme (slightly darker)</li>
<li>Offer more locations to select an image/icon from</li>
<li>Display some helpful information when importing from a different app</li>
<li>Minimum tap to reveal timeout changed to 1 second</li>
<li>After an entry is added, scroll to it and highlight it</li>
<li>Updated translations, and new translations for: Basque, Chinese Traditional, Hindi, Indonesian, Japanese, Persian, Romanian, Slovak</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Scanning large images for QR codes would fail</li>
<li>The FAB would remain hidden under certain circumstances</li>
<li>The app would crash if an entry was added to the vault twice due to an IO error</li>
<li>The app would crash if the device was rotated while a progress dialog was shown</li>
<li>The PIN keyboard would show even if a new non-digit password was set</li>
<li>The password reminder popup would be occluded by the autofill popup</li>
<li>Importing from other apps on Android 11 was broken due to some permission issues</li>
</ul>
<h3>Version 1.3</h3>
<h4>New</h4>
<ul>
<li>Completely rewritten intro/onboarding</li>
<li>Option to show a PIN keyboard when unlocking Aegis</li>
<li>A password strength meter when setting up encryption (based on zxcvbn)</li>
<li>RTL support</li>
<li>Arabic and Portuguese translations</li>
<li>Updates to existing translations</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Better lifecycle handling of the biometric authentication prompt</li>
<li>The filename of exported vaults had a double .json extension</li>
<li>The navigation bar color was incorrect on devices pre API 27</li>
<li>QR code scanner performance and stability improvements</li>
<li>Various other small usability and stability improvements</li>
</ul>
<h3>Version 1.2.1</h3>
<h4>Fixes</h4>
<ul>
<li>Fix a rare issue where the intro could end up in a bad state</li>
</ul>
<h3>Version 1.2</h3>
<h4>New</h4>
<ul>
<li>Add navigation bar color to themes</li>
<li>Add support for importing from TOTP Authenticator</li>
<li>Add support for importing from Microsoft Authenticator</li>
<li>Add support for importing from Authenticator Plus</li>
<li>Add support for importing a plain text Google Authenticator URI file</li>
<li>Add support for importing from the new Google Authenticator export QR codes</li>
<li>Add support for otpauth://steam URI's</li>
<li>Add an option to copy tokens on tap (and disable it by default)</li>
<li>Improve method to notify users on copy</li>
<li>Add support for backups</li>
<li>Improve multiselect flow</li>
<li>Automatically adapt to system theme</li>
<li>Add setting to change from 3 digit group size to 2 digit group size</li>
<li>Use most frequent period to show progress</li>
<li>Append a timestamp to the filename of exported vaults</li>
<li>Add Hungarian translation</li>
<li>Add Turkish translation</li>
<li>Display a warning if automatic time sync is not enabled</li>
<li>Minor card entry layout overhaul</li>
<li>Ability to transfer tokens with qr codes</li>
<li>Lockscreen overhaul</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Improve overall exception handling and error feedback to the user</li>
<li>Improve icon editing flow</li>
<li>Protect writes of the vault file against corruption with AtomicFile</li>
<li>Make the parsing logic of the QR code URI more robust</li>
<li>Importing from Authy now asks for password if needed</li>
<li>Update Russian localization</li>
<li>Increase password reminder period to 30 days</li>
<li>Fix importing andOTP backups with more than 10000 PBKDF iterations</li>
<li>Respect the global animator duration scale setting</li>
</ul>
<p>Various other minor improvements</p>
<h3>Version 1.1.4</h3>
<h4>Fixes</h4>
<ul>
<li>The export filename was missing the ".json" extension in some cases</li>
</ul>
<h3>Version 1.1.3</h3>
<h4>New</h4>
<ul>
<li>Password reminder for users who use biometric unlock</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Tokens would not refresh in some rare cases</li>
</ul>
<h3>Version 1.1.2</h3>
<h4>New</h4>
<ul>
<li>Ability to select multiple entries</li>
<li>Ability to select a file location when exporting the vault (including cloud providers like Google Drive)</li>
<li>Explanation and warning for the security options</li>
<li>Removed external storage permissions</li>
</ul>
<h3>Version 1.1.1</h3>
<h4>Fixes</h4>
<ul>
<li>Exporting the vault did not work on Android 10</li>
</ul>
<h3>Version 1.1</h3>
<h4>New</h4>
<ul>
<li>Support for other types of biometric authentication (i.e. Pixel 4 face unlock)</li>
<li>Support for importing from WinAuth</li>
<li>Support for Chromebooks</li>
<li>Option to highlight entries when tapped</li>
<li>Filter for ungrouped tokens</li>
<li>Ability to search for token account names</li>
<li>Simplified Chinese translation (thanks RunningMelos!)</li>
<li>Updated translations (thanks to all Crowdin contributers!)</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>The behavior of highlighting and revealing entries was inconsistent</li>
<li>The changelog dialog didn't work</li>
<li>The persistent notification was shown even after the app was killed</li>
</ul>
<h3>Version 1.0.3</h3>
<h4>New</h4>
<ul>
<li>Support for andOTP's new backup file format</li>
</ul>
<h3>Version 1.0.2</h3>
<h4>Fixes</h4>
<ul>
<li>Search feature on Huawei devices</li>
</ul>
<h4>Notes</h4>
<ul>
<li>Disabled automatic backups through the Google Play Store</li>
</ul>
<h3>Version 1.0.1</h3>
<h4>Notes</h4>
<ul>
<li>Temporarily disabled search feature on Huawei devices</li>
</ul>
<h3>Version 1.0</h3>
<h4>New</h4>
<ul>
<li>New icon</li>
<li>Overhaul of interaction with the entry list</li>
<li>Persistent notification while the vault is unlocked</li>
<li>Language override option</li>
<li>Support for importing from FreeOTP+</li>
<li>Ability to toggle password visibility during unlock</li>
<li>Support for deeplinking otpauth URIs</li>
</ul>
<h4>Fixes</h4>
<ul>
<li>Bad overall performance and high battery usage</li>
<li>Codes with an uneven number of digits are displayed incorrectly</li>
<li>Crash when entering a large value for OTP period</li>
</ul>
</body>
</html>

View file

@ -0,0 +1,14 @@
<html>
<head>
<style type="text/css">
body {
background-color: %2$s;
color: %3$s;
font-size: 0.8em;
}
</style>
</head>
<body>
<pre>%1$s</pre>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2014 Amulya Khare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,316 @@
package com.amulyakhare.textdrawable;
import android.graphics.*;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.OvalShape;
import android.graphics.drawable.shapes.RectShape;
import android.graphics.drawable.shapes.RoundRectShape;
/**
* @author amulya
* @datetime 14 Oct 2014, 3:53 PM
*/
public class TextDrawable extends ShapeDrawable {
private final Paint textPaint;
private final Paint borderPaint;
private static final float SHADE_FACTOR = 0.9f;
private final String text;
private final int color;
private final RectShape shape;
private final int height;
private final int width;
private final int fontSize;
private final float radius;
private final int borderThickness;
private TextDrawable(Builder builder) {
super(builder.shape);
// shape properties
shape = builder.shape;
height = builder.height;
width = builder.width;
radius = builder.radius;
// text and color
text = builder.toUpperCase ? builder.text.toUpperCase() : builder.text;
color = builder.color;
// text paint settings
fontSize = builder.fontSize;
textPaint = new Paint();
textPaint.setColor(builder.textColor);
textPaint.setAntiAlias(true);
textPaint.setFakeBoldText(builder.isBold);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setTypeface(builder.font);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setStrokeWidth(builder.borderThickness);
// border paint settings
borderThickness = builder.borderThickness;
borderPaint = new Paint();
borderPaint.setColor(getDarkerShade(color));
borderPaint.setStyle(Paint.Style.STROKE);
borderPaint.setStrokeWidth(borderThickness);
// drawable paint color
Paint paint = getPaint();
paint.setColor(color);
}
private int getDarkerShade(int color) {
return Color.rgb((int)(SHADE_FACTOR * Color.red(color)),
(int)(SHADE_FACTOR * Color.green(color)),
(int)(SHADE_FACTOR * Color.blue(color)));
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
Rect r = getBounds();
// draw border
if (borderThickness > 0) {
drawBorder(canvas);
}
int count = canvas.save();
canvas.translate(r.left, r.top);
// draw text
int width = this.width < 0 ? r.width() : this.width;
int height = this.height < 0 ? r.height() : this.height;
int fontSize = this.fontSize < 0 ? (Math.min(width, height) / 2) : this.fontSize;
textPaint.setTextSize(fontSize);
canvas.drawText(text, width / 2, height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint);
canvas.restoreToCount(count);
}
private void drawBorder(Canvas canvas) {
RectF rect = new RectF(getBounds());
rect.inset(borderThickness/2, borderThickness/2);
if (shape instanceof OvalShape) {
canvas.drawOval(rect, borderPaint);
}
else if (shape instanceof RoundRectShape) {
canvas.drawRoundRect(rect, radius, radius, borderPaint);
}
else {
canvas.drawRect(rect, borderPaint);
}
}
@Override
public void setAlpha(int alpha) {
textPaint.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
textPaint.setColorFilter(cf);
}
@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}
@Override
public int getIntrinsicWidth() {
return width;
}
@Override
public int getIntrinsicHeight() {
return height;
}
public static IShapeBuilder builder() {
return new Builder();
}
public static class Builder implements IConfigBuilder, IShapeBuilder, IBuilder {
private String text;
private int color;
private int borderThickness;
private int width;
private int height;
private Typeface font;
private RectShape shape;
public int textColor;
private int fontSize;
private boolean isBold;
private boolean toUpperCase;
public float radius;
private Builder() {
text = "";
color = Color.GRAY;
textColor = Color.WHITE;
borderThickness = 0;
width = -1;
height = -1;
shape = new RectShape();
font = Typeface.create("sans-serif-light", Typeface.NORMAL);
fontSize = -1;
isBold = false;
toUpperCase = false;
}
public IConfigBuilder width(int width) {
this.width = width;
return this;
}
public IConfigBuilder height(int height) {
this.height = height;
return this;
}
public IConfigBuilder textColor(int color) {
this.textColor = color;
return this;
}
public IConfigBuilder withBorder(int thickness) {
this.borderThickness = thickness;
return this;
}
public IConfigBuilder useFont(Typeface font) {
this.font = font;
return this;
}
public IConfigBuilder fontSize(int size) {
this.fontSize = size;
return this;
}
public IConfigBuilder bold() {
this.isBold = true;
return this;
}
public IConfigBuilder toUpperCase() {
this.toUpperCase = true;
return this;
}
@Override
public IConfigBuilder beginConfig() {
return this;
}
@Override
public IShapeBuilder endConfig() {
return this;
}
@Override
public IBuilder rect() {
this.shape = new RectShape();
return this;
}
@Override
public IBuilder round() {
this.shape = new OvalShape();
return this;
}
@Override
public IBuilder roundRect(int radius) {
this.radius = radius;
float[] radii = {radius, radius, radius, radius, radius, radius, radius, radius};
this.shape = new RoundRectShape(radii, null, null);
return this;
}
@Override
public TextDrawable buildRect(String text, int color) {
rect();
return build(text, color);
}
@Override
public TextDrawable buildRoundRect(String text, int color, int radius) {
roundRect(radius);
return build(text, color);
}
@Override
public TextDrawable buildRound(String text, int color) {
round();
return build(text, color);
}
@Override
public TextDrawable build(String text, int color) {
this.color = color;
this.text = text;
return new TextDrawable(this);
}
}
public interface IConfigBuilder {
public IConfigBuilder width(int width);
public IConfigBuilder height(int height);
public IConfigBuilder textColor(int color);
public IConfigBuilder withBorder(int thickness);
public IConfigBuilder useFont(Typeface font);
public IConfigBuilder fontSize(int size);
public IConfigBuilder bold();
public IConfigBuilder toUpperCase();
public IShapeBuilder endConfig();
}
public static interface IBuilder {
public TextDrawable build(String text, int color);
}
public static interface IShapeBuilder {
public IConfigBuilder beginConfig();
public IBuilder rect();
public IBuilder round();
public IBuilder roundRect(int radius);
public TextDrawable buildRect(String text, int color);
public TextDrawable buildRoundRect(String text, int color, int radius);
public TextDrawable buildRound(String text, int color);
}
}

View file

@ -0,0 +1,69 @@
package com.amulyakhare.textdrawable.util;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
/**
* @author amulya
* @datetime 14 Oct 2014, 5:20 PM
*/
public class ColorGenerator {
public static ColorGenerator DEFAULT;
public static ColorGenerator MATERIAL;
static {
DEFAULT = create(Arrays.asList(
0xfff16364,
0xfff58559,
0xfff9a43e,
0xffe4c62e,
0xff67bf74,
0xff59a2be,
0xff2093cd,
0xffad62a7,
0xff805781
));
MATERIAL = create(Arrays.asList(
0xffe57373,
0xfff06292,
0xffba68c8,
0xff9575cd,
0xff7986cb,
0xff64b5f6,
0xff4fc3f7,
0xff4dd0e1,
0xff4db6ac,
0xff81c784,
0xffaed581,
0xffff8a65,
0xffd4e157,
0xffffd54f,
0xffffb74d,
0xffa1887f,
0xff90a4ae
));
}
private final List<Integer> mColors;
private final Random mRandom;
public static ColorGenerator create(List<Integer> colorList) {
return new ColorGenerator(colorList);
}
private ColorGenerator(List<Integer> colorList) {
mColors = colorList;
mRandom = new Random(System.currentTimeMillis());
}
public int getRandomColor() {
return mColors.get(mRandom.nextInt(mColors.size()));
}
public int getColor(Object key) {
return mColors.get(Math.abs(key.hashCode()) % mColors.size());
}
}

View file

@ -0,0 +1,17 @@
package com.beemdevelopment.aegis;
public enum AccountNamePosition {
HIDDEN,
END,
BELOW;
private static AccountNamePosition[] _values;
static {
_values = values();
}
public static AccountNamePosition fromInteger(int x) {
return _values[x];
}
}

View file

@ -0,0 +1,8 @@
package com.beemdevelopment.aegis;
import dagger.hilt.android.HiltAndroidApp;
@HiltAndroidApp
public class AegisApplication extends AegisApplicationBase {
}

View file

@ -0,0 +1,121 @@
package com.beemdevelopment.aegis;
import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.drawable.Icon;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleEventObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner;
import com.beemdevelopment.aegis.receivers.VaultLockReceiver;
import com.beemdevelopment.aegis.ui.MainActivity;
import com.beemdevelopment.aegis.util.IOUtils;
import com.beemdevelopment.aegis.vault.VaultManager;
import com.topjohnwu.superuser.Shell;
import java.util.Collections;
import dagger.hilt.InstallIn;
import dagger.hilt.android.EarlyEntryPoint;
import dagger.hilt.android.EarlyEntryPoints;
import dagger.hilt.components.SingletonComponent;
public abstract class AegisApplicationBase extends Application {
private static final String CODE_LOCK_STATUS_ID = "lock_status_channel";
private VaultManager _vaultManager;
static {
// Enable verbose libsu logging in debug builds
Shell.enableVerboseLogging = BuildConfig.DEBUG;
}
@Override
public void onCreate() {
super.onCreate();
_vaultManager = EarlyEntryPoints.get(this, EntryPoint.class).getVaultManager();
VaultLockReceiver lockReceiver = new VaultLockReceiver();
IntentFilter intentFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
ContextCompat.registerReceiver(this, lockReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED);
// lock the app if the user moves the application to the background
ProcessLifecycleOwner.get().getLifecycle().addObserver(new AppLifecycleObserver());
// clear the cache directory on startup, to make sure no temporary vault export files remain
IOUtils.clearDirectory(getCacheDir(), false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
initAppShortcuts();
}
// NOTE: Disabled for now. See issue: #1047
/*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
initNotificationChannels();
}*/
}
@RequiresApi(api = Build.VERSION_CODES.N_MR1)
private void initAppShortcuts() {
ShortcutManager shortcutManager = getSystemService(ShortcutManager.class);
if (shortcutManager == null) {
return;
}
Intent intent = new Intent(this, MainActivity.class);
intent.putExtra("action", "scan");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.setAction(Intent.ACTION_MAIN);
ShortcutInfo shortcut = new ShortcutInfo.Builder(this, "shortcut_new")
.setShortLabel(getString(R.string.new_entry))
.setLongLabel(getString(R.string.add_new_entry))
.setIcon(Icon.createWithResource(this, R.drawable.ic_qr_code))
.setIntent(intent)
.build();
shortcutManager.setDynamicShortcuts(Collections.singletonList(shortcut));
}
private void initNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
CharSequence name = getString(R.string.channel_name_lock_status);
String description = getString(R.string.channel_description_lock_status);
int importance = NotificationManager.IMPORTANCE_LOW;
NotificationChannel channel = new NotificationChannel(CODE_LOCK_STATUS_ID, name, importance);
channel.setDescription(description);
NotificationManager notificationManager = getSystemService(NotificationManager.class);
notificationManager.createNotificationChannel(channel);
}
}
private class AppLifecycleObserver implements LifecycleEventObserver {
@Override
public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_STOP
&& _vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_MINIMIZE)
&& !_vaultManager.isAutoLockBlocked()) {
_vaultManager.lock(false);
}
}
}
@EarlyEntryPoint
@InstallIn(SingletonComponent.class)
interface EntryPoint {
VaultManager getVaultManager();
}
}

View file

@ -0,0 +1,147 @@
package com.beemdevelopment.aegis;
import android.app.backup.BackupAgent;
import android.app.backup.BackupDataInput;
import android.app.backup.BackupDataOutput;
import android.app.backup.FullBackupDataOutput;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import com.beemdevelopment.aegis.database.AppDatabase;
import com.beemdevelopment.aegis.database.AuditLogRepository;
import com.beemdevelopment.aegis.util.IOUtils;
import com.beemdevelopment.aegis.vault.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 = AegisBackupAgent.class.getSimpleName();
private Preferences _prefs;
private AuditLogRepository _auditLogRepository;
@Override
public void onCreate() {
super.onCreate();
// Cannot use injection with Dagger Hilt here, because the app is launched in a restricted mode on restore
_prefs = new Preferences(this);
AppDatabase appDatabase = AegisModule.provideAppDatabase(this);
_auditLogRepository = AegisModule.provideAuditLogRepository(appDatabase);
}
@Override
public synchronized void onFullBackup(FullBackupDataOutput data) throws IOException {
Log.i(TAG, String.format("onFullBackup() called: flags=%d, quota=%d",
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P ? data.getTransportFlags() : -1,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? data.getQuota() : -1));
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;
}
// We perform a catch of any Exception here to make sure we also
// report any runtime exceptions, in addition to the expected IOExceptions.
try {
fullBackup(data);
_auditLogRepository.addAndroidBackupCreatedEvent();
_prefs.setAndroidBackupResult(new Preferences.BackupResult(null));
} catch (Exception e) {
Log.e(TAG, String.format("onFullBackup() failed: %s", e));
_prefs.setAndroidBackupResult(new Preferences.BackupResult(e));
throw e;
}
Log.i(TAG, "onFullBackup() finished");
}
private void fullBackup(FullBackupDataOutput data) throws IOException {
// First copy the vault to the files/backup directory
createBackupDir();
File vaultBackupFile = getVaultBackupFile();
try (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));
super.onRestoreFile(data, size, destination, type, mode, mtime);
File vaultBackupFile = getVaultBackupFile();
if (destination.getCanonicalFile().equals(vaultBackupFile.getCanonicalFile())) {
try (InputStream inStream = new FileInputStream(vaultBackupFile)) {
VaultRepository.writeToFile(this, inStream);
} catch (IOException e) {
Log.e(TAG, String.format("onRestoreFile() failed: dest=%s, error=%s", destination, e));
throw e;
} finally {
deleteBackupDir();
}
}
Log.i(TAG, String.format("onRestoreFile() finished: dest=%s", destination));
}
@Override
public synchronized void onQuotaExceeded(long backupDataBytes, long quotaBytes) {
super.onQuotaExceeded(backupDataBytes, quotaBytes);
Log.e(TAG, String.format("onQuotaExceeded() called: backupDataBytes=%d, quotaBytes=%d", backupDataBytes, quotaBytes));
}
@Override
public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) throws IOException {
}
@Override
public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) throws IOException {
}
private void createBackupDir() throws IOException {
File dir = getVaultBackupFile().getParentFile();
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();
if (dir != null) {
IOUtils.clearDirectory(dir, true);
}
}
private File getVaultBackupFile() {
return new File(new File(getFilesDir(), "backup"), VaultRepository.FILENAME);
}
}

View file

@ -0,0 +1,55 @@
package com.beemdevelopment.aegis;
import android.content.Context;
import androidx.room.Room;
import com.beemdevelopment.aegis.database.AppDatabase;
import com.beemdevelopment.aegis.database.AuditLogDao;
import com.beemdevelopment.aegis.database.AuditLogRepository;
import com.beemdevelopment.aegis.icons.IconPackManager;
import com.beemdevelopment.aegis.vault.VaultManager;
import javax.inject.Singleton;
import dagger.Module;
import dagger.Provides;
import dagger.hilt.InstallIn;
import dagger.hilt.android.qualifiers.ApplicationContext;
import dagger.hilt.components.SingletonComponent;
@Module
@InstallIn(SingletonComponent.class)
public class AegisModule {
@Provides
@Singleton
public static IconPackManager provideIconPackManager(@ApplicationContext Context context) {
return new IconPackManager(context);
}
@Provides
@Singleton
public static AuditLogRepository provideAuditLogRepository(AppDatabase appDatabase) {
AuditLogDao auditLogDao = appDatabase.auditLogDao();
return new AuditLogRepository(auditLogDao);
}
@Provides
@Singleton
public static VaultManager provideVaultManager(@ApplicationContext Context context, AuditLogRepository auditLogRepository) {
return new VaultManager(context, auditLogRepository);
}
@Provides
public static Preferences providePreferences(@ApplicationContext Context context) {
return new Preferences(context);
}
@Provides
@Singleton
public static AppDatabase provideAppDatabase(@ApplicationContext Context context) {
return Room.databaseBuilder(context.getApplicationContext(),
AppDatabase.class, "aegis-db")
.build();
}
}

View file

@ -0,0 +1,7 @@
package com.beemdevelopment.aegis;
public enum BackupsVersioningStrategy {
UNDEFINED,
MULTIPLE_BACKUPS,
SINGLE_BACKUP
}

View file

@ -0,0 +1,17 @@
package com.beemdevelopment.aegis;
public enum CopyBehavior {
NEVER,
SINGLETAP,
DOUBLETAP;
private static CopyBehavior[] _values;
static {
_values = values();
}
public static CopyBehavior fromInteger(int x) {
return _values[x];
}
}

View file

@ -0,0 +1,42 @@
package com.beemdevelopment.aegis;
public enum EventType {
VAULT_UNLOCKED,
VAULT_BACKUP_CREATED,
VAULT_ANDROID_BACKUP_CREATED,
VAULT_EXPORTED,
ENTRY_SHARED,
VAULT_UNLOCK_FAILED_PASSWORD,
VAULT_UNLOCK_FAILED_BIOMETRICS;
private static EventType[] _values;
static {
_values = values();
}
public static EventType fromInteger(int x) {
return _values[x];
}
public static int getEventTitleRes(EventType eventType) {
switch (eventType) {
case VAULT_UNLOCKED:
return R.string.event_title_vault_unlocked;
case VAULT_BACKUP_CREATED:
return R.string.event_title_backup_created;
case VAULT_ANDROID_BACKUP_CREATED:
return R.string.event_title_android_backup_created;
case VAULT_EXPORTED:
return R.string.event_title_vault_exported;
case ENTRY_SHARED:
return R.string.event_title_entry_shared;
case VAULT_UNLOCK_FAILED_PASSWORD:
return R.string.event_title_vault_unlock_failed_password;
case VAULT_UNLOCK_FAILED_BIOMETRICS:
return R.string.event_title_vault_unlock_failed_biometrics;
default:
return R.string.event_unknown;
}
}
}

View file

@ -0,0 +1,20 @@
package com.beemdevelopment.aegis;
public enum GroupPlaceholderType {
ALL,
NEW_GROUP,
NO_GROUP;
public int getStringRes() {
switch (this) {
case ALL:
return R.string.all;
case NEW_GROUP:
return R.string.new_group;
case NO_GROUP:
return R.string.no_group;
default:
throw new IllegalArgumentException("Unexpected placeholder type: " + this);
}
}
}

View file

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

View file

@ -0,0 +1,707 @@
package com.beemdevelopment.aegis;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.provider.DocumentsContractCompat;
import androidx.preference.PreferenceManager;
import com.beemdevelopment.aegis.util.JsonUtils;
import com.beemdevelopment.aegis.util.TimeUtils;
import com.beemdevelopment.aegis.vault.VaultBackupPermissionException;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
public class Preferences {
public static final int AUTO_LOCK_OFF = 1 << 0;
public static final int AUTO_LOCK_ON_BACK_BUTTON = 1 << 1;
public static final int AUTO_LOCK_ON_MINIMIZE = 1 << 2;
public static final int AUTO_LOCK_ON_DEVICE_LOCK = 1 << 3;
public static final int SEARCH_IN_ISSUER = 1 << 0;
public static final int SEARCH_IN_NAME = 1 << 1;
public static final int SEARCH_IN_NOTE = 1 << 2;
public static final int SEARCH_IN_GROUPS = 1 << 3;
public static final int BACKUPS_VERSIONS_INFINITE = -1;
public static final int[] AUTO_LOCK_SETTINGS = {
AUTO_LOCK_ON_BACK_BUTTON,
AUTO_LOCK_ON_MINIMIZE,
AUTO_LOCK_ON_DEVICE_LOCK
};
public static final int[] SEARCH_BEHAVIOR_SETTINGS = {
SEARCH_IN_ISSUER,
SEARCH_IN_NAME,
SEARCH_IN_NOTE,
SEARCH_IN_GROUPS
};
private SharedPreferences _prefs;
public Preferences(Context context) {
_prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (getPasswordReminderTimestamp().getTime() == 0) {
resetPasswordReminderTimestamp();
}
migratePreferences();
}
public void migratePreferences() {
// Change copy on tap to copy behavior to new preference and delete the old key
String prefCopyOnTapKey = "pref_copy_on_tap";
if (_prefs.contains(prefCopyOnTapKey)) {
boolean isCopyOnTapEnabled = _prefs.getBoolean(prefCopyOnTapKey, false);
if (isCopyOnTapEnabled) {
setCopyBehavior(CopyBehavior.SINGLETAP);
}
_prefs.edit().remove(prefCopyOnTapKey).apply();
}
}
public boolean isTapToRevealEnabled() {
return _prefs.getBoolean("pref_tap_to_reveal", false);
}
public boolean isGroupMultiselectEnabled() {
return _prefs.getBoolean("pref_groups_multiselect", false);
}
public boolean isEntryHighlightEnabled() {
return _prefs.getBoolean("pref_highlight_entry", false);
}
public boolean isHapticFeedbackEnabled() {
return _prefs.getBoolean("pref_haptic_feedback", true);
}
public boolean isPauseFocusedEnabled() {
boolean dependenciesEnabled = isTapToRevealEnabled() || isEntryHighlightEnabled();
if (!dependenciesEnabled) return false;
return _prefs.getBoolean("pref_pause_entry", false);
}
public boolean isPanicTriggerEnabled() {
return _prefs.getBoolean("pref_panic_trigger", false);
}
public void setIsPanicTriggerEnabled(boolean enabled) {
_prefs.edit().putBoolean("pref_panic_trigger", enabled).apply();
}
public boolean isSecureScreenEnabled() {
// screen security should be enabled by default, but not for debug builds
return _prefs.getBoolean("pref_secure_screen", !BuildConfig.DEBUG);
}
public 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() {
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() {
setPasswordReminderTimestamp(new Date().getTime());
}
public boolean onlyShowNecessaryAccountNames() { return _prefs.getBoolean("pref_shared_issuer_account_name", false); }
public boolean isIconVisible() {
return _prefs.getBoolean("pref_show_icons", true);
}
public boolean getShowNextCode() {
return _prefs.getBoolean("pref_show_next_code", false);
}
public boolean getShowExpirationState() {
return _prefs.getBoolean("pref_expiration_state", true);
}
public CodeGrouping getCodeGroupSize() {
String value = _prefs.getString("pref_code_group_size_string", "GROUPING_THREES");
return CodeGrouping.valueOf(value);
}
public void setCodeGroupSize(CodeGrouping codeGroupSize) {
_prefs.edit().putString("pref_code_group_size_string", codeGroupSize.name()).apply();
}
public boolean isIntroDone() {
return _prefs.getBoolean("pref_intro", false);
}
private int getAutoLockMask() {
final int def = AUTO_LOCK_ON_BACK_BUTTON | AUTO_LOCK_ON_DEVICE_LOCK;
if (!_prefs.contains("pref_auto_lock_mask")) {
return _prefs.getBoolean("pref_auto_lock", true) ? def : AUTO_LOCK_OFF;
}
return _prefs.getInt("pref_auto_lock_mask", def);
}
public int getSearchBehaviorMask() {
final int def = SEARCH_IN_ISSUER | SEARCH_IN_NAME;
return _prefs.getInt("pref_search_behavior_mask", def);
}
public boolean isSearchBehaviorTypeEnabled(int searchBehaviorType) {
return (getSearchBehaviorMask() & searchBehaviorType) == searchBehaviorType;
}
public void setSearchBehaviorMask(int searchBehavior) {
_prefs.edit().putInt("pref_search_behavior_mask", searchBehavior).apply();
}
public boolean isAutoLockEnabled() {
return getAutoLockMask() != AUTO_LOCK_OFF;
}
public boolean isAutoLockTypeEnabled(int autoLockType) {
return (getAutoLockMask() & autoLockType) == autoLockType;
}
public void setAutoLockMask(int autoLock) {
_prefs.edit().putInt("pref_auto_lock_mask", autoLock).apply();
}
public void setIntroDone(boolean done) {
_prefs.edit().putBoolean("pref_intro", done).apply();
}
public void setTapToRevealTime(int number) {
_prefs.edit().putInt("pref_tap_to_reveal_time", number).apply();
}
public void setCurrentSortCategory(SortCategory category) {
_prefs.edit().putInt("pref_current_sort_category", category.ordinal()).apply();
}
public SortCategory getCurrentSortCategory() {
return SortCategory.fromInteger(_prefs.getInt("pref_current_sort_category", 0));
}
public int getTapToRevealTime() {
return _prefs.getInt("pref_tap_to_reveal_time", 30);
}
public Theme getCurrentTheme() {
return Theme.fromInteger(_prefs.getInt("pref_current_theme", Theme.SYSTEM.ordinal()));
}
public void setCurrentTheme(Theme theme) {
_prefs.edit().putInt("pref_current_theme", theme.ordinal()).apply();
}
public boolean isDynamicColorsEnabled() {
return _prefs.getBoolean("pref_dynamic_colors", false);
}
public ViewMode getCurrentViewMode() {
return ViewMode.fromInteger(_prefs.getInt("pref_current_view_mode", 0));
}
public void setCurrentViewMode(ViewMode viewMode) {
_prefs.edit().putInt("pref_current_view_mode", viewMode.ordinal()).apply();
}
public AccountNamePosition getAccountNamePosition() {
return AccountNamePosition.fromInteger(_prefs.getInt("pref_account_name_position", AccountNamePosition.END.ordinal()));
}
public void setAccountNamePosition(AccountNamePosition accountNamePosition) {
_prefs.edit().putInt("pref_account_name_position", accountNamePosition.ordinal()).apply();
}
public Integer getUsageCount(UUID uuid) {
Integer usageCount = getUsageCounts().get(uuid);
return usageCount != null ? usageCount : 0;
}
public void resetUsageCount(UUID uuid) {
Map<UUID, Integer> usageCounts = getUsageCounts();
usageCounts.put(uuid, 0);
setUsageCount(usageCounts);
}
public long getLastUsedTimestamp(UUID uuid) {
Map<UUID, Long> timestamps = getLastUsedTimestamps();
if (timestamps != null && timestamps.size() > 0){
Long timestamp = timestamps.get(uuid);
return timestamp != null ? timestamp : 0;
}
return 0;
}
public void clearUsageCount() {
_prefs.edit().remove("pref_usage_count").apply();
}
public Map<UUID, Long> getLastUsedTimestamps() {
Map<UUID, Long> lastUsedTimestamps = new HashMap<>();
String lastUsedTimestamp = _prefs.getString("pref_last_used_timestamps", "");
try {
JSONArray arr = new JSONArray(lastUsedTimestamp);
for (int i = 0; i < arr.length(); i++) {
JSONObject json = arr.getJSONObject(i);
lastUsedTimestamps.put(UUID.fromString(json.getString("uuid")), json.getLong("timestamp"));
}
} catch (JSONException ignored) {
}
return lastUsedTimestamps;
}
public void setLastUsedTimestamps(Map<UUID, Long> lastUsedTimestamps) {
JSONArray lastUsedTimestampJson = new JSONArray();
for (Map.Entry<UUID, Long> entry : lastUsedTimestamps.entrySet()) {
JSONObject entryJson = new JSONObject();
try {
entryJson.put("uuid", entry.getKey());
entryJson.put("timestamp", entry.getValue());
lastUsedTimestampJson.put(entryJson);
} catch (JSONException e) {
e.printStackTrace();
}
}
_prefs.edit().putString("pref_last_used_timestamps", lastUsedTimestampJson.toString()).apply();
}
public Map<UUID, Integer> getUsageCounts() {
Map<UUID, Integer> usageCounts = new HashMap<>();
String usageCount = _prefs.getString("pref_usage_count", "");
try {
JSONArray arr = new JSONArray(usageCount);
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 ignored) {
}
return usageCounts;
}
public void setUsageCount(Map<UUID, Integer> usageCounts) {
JSONArray usageCountJson = new JSONArray();
for (Map.Entry<UUID, Integer> entry : usageCounts.entrySet()) {
JSONObject entryJson = new JSONObject();
try {
entryJson.put("uuid", entry.getKey());
entryJson.put("count", entry.getValue());
usageCountJson.put(entryJson);
} catch (JSONException e) {
e.printStackTrace();
}
}
_prefs.edit().putString("pref_usage_count", usageCountJson.toString()).apply();
}
public int getTimeout() {
return _prefs.getInt("pref_timeout", -1);
}
public String getLanguage() {
return _prefs.getString("pref_lang", "system");
}
public void setLanguage(String lang) {
_prefs.edit().putString("pref_lang", lang).apply();
}
public Locale getLocale() {
String lang = getLanguage();
if (lang.equals("system")) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return Resources.getSystem().getConfiguration().getLocales().get(0);
} else {
return Resources.getSystem().getConfiguration().locale;
}
}
String[] parts = lang.split("_");
if (parts.length == 1) {
return new Locale(parts[0]);
}
return new Locale(parts[0], parts[1]);
}
public boolean isAndroidBackupsEnabled() {
return _prefs.getBoolean("pref_android_backups", false);
}
public void setIsAndroidBackupsEnabled(boolean enabled) {
_prefs.edit().putBoolean("pref_android_backups", enabled).apply();
setAndroidBackupResult(null);
}
public boolean isBackupsEnabled() {
return _prefs.getBoolean("pref_backups", false);
}
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() {
String str = _prefs.getString("pref_backups_location", null);
if (str != null) {
return Uri.parse(str);
}
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();
}
public int getBackupsVersionCount() {
return _prefs.getInt("pref_backups_versions", 5);
}
public void setBackupsVersionCount(int versions) {
_prefs.edit().putInt("pref_backups_versions", versions).apply();
}
public void setAndroidBackupResult(@Nullable BackupResult res) {
setBackupResult(false, res);
}
public void setBuiltInBackupResult(@Nullable BackupResult res) {
setBackupResult(true, res);
}
@Nullable
public BackupResult getAndroidBackupResult() {
return getBackupResult(false);
}
@Nullable
public BackupResult getBuiltInBackupResult() {
return getBackupResult(true);
}
@Nullable
public Preferences.BackupResult getErroredBackupResult() {
Preferences.BackupResult res = getBuiltInBackupResult();
if (res != null && !res.isSuccessful()) {
return res;
}
res = getAndroidBackupResult();
if (res != null && !res.isSuccessful()) {
return res;
}
return null;
}
private void setBackupResult(boolean isBuiltInBackup, @Nullable BackupResult res) {
String json = null;
if (res != null) {
res.setIsBuiltIn(isBuiltInBackup);
json = res.toJson();
}
_prefs.edit().putString(getBackupResultKey(isBuiltInBackup), json).apply();
}
@Nullable
private BackupResult getBackupResult(boolean isBuiltInBackup) {
String json = _prefs.getString(getBackupResultKey(isBuiltInBackup), null);
if (json == null) {
return null;
}
try {
BackupResult res = BackupResult.fromJson(json);
res.setIsBuiltIn(isBuiltInBackup);
return res;
} catch (JSONException e) {
return null;
}
}
private static String getBackupResultKey(boolean isBuiltInBackup) {
return isBuiltInBackup ? "pref_backups_result_builtin": "pref_backups_result_android";
}
public void setIsBackupReminderNeeded(boolean needed) {
if (isBackupsReminderNeeded() != needed) {
_prefs.edit().putBoolean("pref_backups_reminder_needed", needed).apply();
}
}
public boolean isBackupsReminderNeeded() {
return _prefs.getBoolean("pref_backups_reminder_needed", false);
}
public void setIsPlaintextBackupWarningNeeded(boolean needed) {
_prefs.edit().putBoolean("pref_plaintext_backup_warning_needed", needed).apply();
}
public boolean isPlaintextBackupWarningNeeded() {
return !isPlaintextBackupWarningDisabled()
&& _prefs.getBoolean("pref_plaintext_backup_warning_needed", false);
}
public void setIsPlaintextBackupWarningDisabled(boolean disabled) {
_prefs.edit().putBoolean("pref_plaintext_backup_warning_disabled", disabled).apply();
}
public boolean isPlaintextBackupWarningDisabled() {
return _prefs.getBoolean("pref_plaintext_backup_warning_disabled", false);
}
public boolean isPinKeyboardEnabled() {
return _prefs.getBoolean("pref_pin_keyboard", false);
}
public boolean isTimeSyncWarningEnabled() {
return _prefs.getBoolean("pref_warn_time_sync", true);
}
public void setIsTimeSyncWarningEnabled(boolean enabled) {
_prefs.edit().putBoolean("pref_warn_time_sync", enabled).apply();
}
public CopyBehavior getCopyBehavior() {
return CopyBehavior.fromInteger(_prefs.getInt("pref_current_copy_behavior", 0));
}
public void setCopyBehavior(CopyBehavior copyBehavior) {
_prefs.edit().putInt("pref_current_copy_behavior", copyBehavior.ordinal()).apply();
}
public boolean isMinimizeOnCopyEnabled() {
return _prefs.getBoolean("pref_minimize_on_copy", false);
}
public void setGroupFilter(Set<UUID> groupFilter) {
JSONArray json = new JSONArray(groupFilter);
_prefs.edit().putString("pref_group_filter_uuids", json.toString()).apply();
}
public Set<UUID> getGroupFilter() {
String raw = _prefs.getString("pref_group_filter_uuids", null);
if (raw == null || raw.isEmpty()) {
return Collections.emptySet();
}
try {
JSONArray json = new JSONArray(raw);
Set<UUID> filter = new HashSet<>();
for (int i = 0; i < json.length(); i++) {
filter.add(json.isNull(i) ? null : UUID.fromString(json.getString(i)));
}
return filter;
} catch (JSONException e) {
return Collections.emptySet();
}
}
@NonNull
public BackupsVersioningStrategy getBackupVersioningStrategy() {
Uri uri = getBackupsLocation();
if (uri == null) {
return BackupsVersioningStrategy.UNDEFINED;
}
if (DocumentsContractCompat.isTreeUri(uri)) {
return BackupsVersioningStrategy.MULTIPLE_BACKUPS;
} else {
return BackupsVersioningStrategy.SINGLE_BACKUP;
}
}
public static class BackupResult {
private final Date _time;
private boolean _isBuiltIn;
private final String _error;
private final boolean _isPermissionError;
public BackupResult(@Nullable Exception e) {
this(new Date(), e == null ? null : e.toString(), e instanceof VaultBackupPermissionException);
}
private BackupResult(Date time, @Nullable String error, boolean isPermissionError) {
_time = time;
_error = error;
_isPermissionError = isPermissionError;
}
@Nullable
public String getError() {
return _error;
}
public boolean isSuccessful() {
return _error == null;
}
public Date getTime() {
return _time;
}
public String getElapsedSince(Context context) {
return TimeUtils.getElapsedSince(context, _time);
}
public boolean isBuiltIn() {
return _isBuiltIn;
}
private void setIsBuiltIn(boolean isBuiltIn) {
_isBuiltIn = isBuiltIn;
}
public boolean isPermissionError() {
return _isPermissionError;
}
public String toJson() {
JSONObject obj = new JSONObject();
try {
obj.put("time", _time.getTime());
obj.put("error", _error == null ? JSONObject.NULL : _error);
obj.put("isPermissionError", _isPermissionError);
} catch (JSONException e) {
throw new RuntimeException(e);
}
return obj.toString();
}
public static BackupResult fromJson(String json) throws JSONException {
JSONObject obj = new JSONObject(json);
long time = obj.getLong("time");
String error = JsonUtils.optString(obj, "error");
boolean isPermissionError = obj.optBoolean("isPermissionError");
return new BackupResult(new Date(time), error, isPermissionError);
}
}
public enum CodeGrouping {
HALVES(-1),
NO_GROUPING(-2),
GROUPING_TWOS(2),
GROUPING_THREES(3),
GROUPING_FOURS(4);
private final int _value;
CodeGrouping(int value) {
_value = value;
}
public int getValue() {
return _value;
}
}
}

View file

@ -0,0 +1,77 @@
package com.beemdevelopment.aegis;
import com.beemdevelopment.aegis.helpers.comparators.LastUsedComparator;
import com.beemdevelopment.aegis.helpers.comparators.UsageCountComparator;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.helpers.comparators.AccountNameComparator;
import com.beemdevelopment.aegis.helpers.comparators.IssuerNameComparator;
import java.util.Collections;
import java.util.Comparator;
public enum SortCategory {
CUSTOM,
ACCOUNT,
ACCOUNT_REVERSED,
ISSUER,
ISSUER_REVERSED,
USAGE_COUNT,
LAST_USED;
private static SortCategory[] _values;
static {
_values = values();
}
public static SortCategory fromInteger(int x) {
return _values[x];
}
public Comparator<VaultEntry> getComparator() {
Comparator<VaultEntry> comparator = null;
switch (this) {
case ACCOUNT:
comparator = new AccountNameComparator().thenComparing(new IssuerNameComparator());
break;
case ACCOUNT_REVERSED:
comparator = Collections.reverseOrder(new AccountNameComparator().thenComparing(new IssuerNameComparator()));
break;
case ISSUER:
comparator = new IssuerNameComparator().thenComparing(new AccountNameComparator());
break;
case ISSUER_REVERSED:
comparator = Collections.reverseOrder(new IssuerNameComparator().thenComparing(new AccountNameComparator()));
break;
case USAGE_COUNT:
comparator = Collections.reverseOrder(new UsageCountComparator());
break;
case LAST_USED:
comparator = Collections.reverseOrder(new LastUsedComparator());
}
return comparator;
}
public int getMenuItem() {
switch (this) {
case CUSTOM:
return R.id.menu_sort_custom;
case ACCOUNT:
return R.id.menu_sort_alphabetically_name;
case ACCOUNT_REVERSED:
return R.id.menu_sort_alphabetically_name_reverse;
case ISSUER:
return R.id.menu_sort_alphabetically;
case ISSUER_REVERSED:
return R.id.menu_sort_alphabetically_reverse;
case USAGE_COUNT:
return R.id.menu_sort_usage_count;
case LAST_USED:
return R.id.menu_sort_last_used;
default:
return R.id.menu_sort_custom;
}
}
}

View file

@ -0,0 +1,19 @@
package com.beemdevelopment.aegis;
public enum Theme {
LIGHT,
DARK,
AMOLED,
SYSTEM,
SYSTEM_AMOLED;
private static Theme[] _values;
static {
_values = values();
}
public static Theme fromInteger(int x) {
return _values[x];
}
}

View file

@ -0,0 +1,17 @@
package com.beemdevelopment.aegis;
import com.google.common.collect.ImmutableMap;
import java.util.Map;
public class ThemeMap {
private ThemeMap() {
}
public static final Map<Theme, Integer> DEFAULT = ImmutableMap.of(
Theme.LIGHT, R.style.Theme_Aegis_Light,
Theme.DARK, R.style.Theme_Aegis_Dark,
Theme.AMOLED, R.style.Theme_Aegis_Amoled
);
}

View file

@ -0,0 +1,12 @@
package com.beemdevelopment.aegis;
import java.util.Arrays;
public class VibrationPatterns {
public static final long[] EXPIRING = {475, 20, 5, 20, 965, 20, 5, 20, 965, 20, 5, 20, 420};
public static final long[] REFRESH_CODE = {0, 100};
public static long getLengthInMillis(long[] pattern) {
return Arrays.stream(pattern).sum();
}
}

View file

@ -0,0 +1,65 @@
package com.beemdevelopment.aegis;
import androidx.annotation.LayoutRes;
public enum ViewMode {
NORMAL,
COMPACT,
SMALL,
TILES;
private static ViewMode[] _values;
static {
_values = values();
}
public static ViewMode fromInteger(int x) {
return _values[x];
}
@LayoutRes
public int getLayoutId() {
switch (this) {
case NORMAL:
return R.layout.card_entry;
case COMPACT:
return R.layout.card_entry_compact;
case SMALL:
return R.layout.card_entry_small;
case TILES:
return R.layout.card_entry_tile;
default:
return R.layout.card_entry;
}
}
/**
* Retrieves the offset (in dp) that should exist between entries in this view mode.
*/
public float getItemOffset() {
if (this == ViewMode.COMPACT) {
return 1;
} else if (this == ViewMode.TILES) {
return 4;
}
return 8;
}
public int getSpanCount() {
if (this == ViewMode.TILES) {
return 2;
}
return 1;
}
public String getFormattedAccountName(String accountName) {
if (this == ViewMode.TILES) {
return accountName;
}
return String.format("(%s)", accountName);
}
}

View file

@ -0,0 +1,46 @@
package com.beemdevelopment.aegis.crypto;
import com.beemdevelopment.aegis.encoding.EncodingException;
import com.beemdevelopment.aegis.encoding.Hex;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.Serializable;
public class CryptParameters implements Serializable {
private byte[] _nonce;
private byte[] _tag;
public CryptParameters(byte[] nonce, byte[] tag) {
_nonce = nonce;
_tag = tag;
}
public JSONObject toJson() {
JSONObject obj = new JSONObject();
try {
obj.put("nonce", Hex.encode(_nonce));
obj.put("tag", Hex.encode(_tag));
} catch (JSONException e) {
throw new RuntimeException(e);
}
return obj;
}
public static CryptParameters fromJson(JSONObject obj) throws JSONException, EncodingException {
byte[] nonce = Hex.decode(obj.getString("nonce"));
byte[] tag = Hex.decode(obj.getString("tag"));
return new CryptParameters(nonce, tag);
}
public byte[] getNonce() {
return _nonce;
}
public byte[] getTag() {
return _tag;
}
}

View file

@ -0,0 +1,19 @@
package com.beemdevelopment.aegis.crypto;
public class CryptResult {
private byte[] _data;
private CryptParameters _params;
public CryptResult(byte[] data, CryptParameters params) {
_data = data;
_params = params;
}
public byte[] getData() {
return _data;
}
public CryptParameters getParams() {
return _params;
}
}

View file

@ -0,0 +1,138 @@
package com.beemdevelopment.aegis.crypto;
import com.beemdevelopment.aegis.crypto.bc.SCrypt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.AlgorithmParameterSpec;
import java.util.Arrays;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class CryptoUtils {
public static final String CRYPTO_AEAD = "AES/GCM/NoPadding";
public static final byte CRYPTO_AEAD_KEY_SIZE = 32;
public static final byte CRYPTO_AEAD_TAG_SIZE = 16;
public static final byte CRYPTO_AEAD_NONCE_SIZE = 12;
public static final int CRYPTO_SCRYPT_N = 1 << 15;
public static final int CRYPTO_SCRYPT_r = 8;
public static final int CRYPTO_SCRYPT_p = 1;
public static SecretKey deriveKey(byte[] input, SCryptParameters params) {
byte[] keyBytes = SCrypt.generate(input, params.getSalt(), params.getN(), params.getR(), params.getP(), CRYPTO_AEAD_KEY_SIZE);
return new SecretKeySpec(keyBytes, 0, keyBytes.length, "AES");
}
public static SecretKey deriveKey(char[] password, SCryptParameters params) {
byte[] bytes = toBytes(password);
return deriveKey(bytes, params);
}
public static Cipher createEncryptCipher(SecretKey key)
throws NoSuchPaddingException, NoSuchAlgorithmException,
InvalidAlgorithmParameterException, InvalidKeyException {
return createCipher(key, Cipher.ENCRYPT_MODE, null);
}
public static Cipher createDecryptCipher(SecretKey key, byte[] nonce)
throws InvalidAlgorithmParameterException, NoSuchAlgorithmException,
InvalidKeyException, NoSuchPaddingException {
return createCipher(key, Cipher.DECRYPT_MODE, nonce);
}
private static Cipher createCipher(SecretKey key, int opmode, byte[] nonce)
throws NoSuchPaddingException, NoSuchAlgorithmException,
InvalidAlgorithmParameterException, InvalidKeyException {
Cipher cipher = Cipher.getInstance(CRYPTO_AEAD);
// generate the nonce if none is given
// we are not allowed to do this ourselves as "setRandomizedEncryptionRequired" is set to true
if (nonce != null) {
AlgorithmParameterSpec spec = new GCMParameterSpec(CRYPTO_AEAD_TAG_SIZE * 8, nonce);
cipher.init(opmode, key, spec);
} else {
cipher.init(opmode, key);
}
return cipher;
}
public static CryptResult encrypt(byte[] data, Cipher cipher)
throws BadPaddingException, IllegalBlockSizeException {
// split off the tag to store it separately
byte[] result = cipher.doFinal(data);
byte[] tag = Arrays.copyOfRange(result, result.length - CRYPTO_AEAD_TAG_SIZE, result.length);
byte[] encrypted = Arrays.copyOfRange(result, 0, result.length - CRYPTO_AEAD_TAG_SIZE);
return new CryptResult(encrypted, new CryptParameters(cipher.getIV(), tag));
}
public static CryptResult decrypt(byte[] encrypted, Cipher cipher, CryptParameters params)
throws IOException, BadPaddingException, IllegalBlockSizeException {
return decrypt(encrypted, 0, encrypted.length, cipher, params);
}
public static CryptResult decrypt(byte[] encrypted, int encryptedOffset, int encryptedLen, Cipher cipher, CryptParameters params)
throws IOException, BadPaddingException, IllegalBlockSizeException {
// append the tag to the ciphertext
ByteArrayOutputStream stream = new ByteArrayOutputStream();
stream.write(encrypted, encryptedOffset, encryptedLen);
stream.write(params.getTag());
encrypted = stream.toByteArray();
byte[] decrypted = cipher.doFinal(encrypted);
return new CryptResult(decrypted, params);
}
public static SecretKey generateKey() {
try {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(CRYPTO_AEAD_KEY_SIZE * 8);
return generator.generateKey();
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
public static byte[] generateSalt() {
return generateRandomBytes(CRYPTO_AEAD_KEY_SIZE);
}
public static byte[] generateRandomBytes(int length) {
SecureRandom random = new SecureRandom();
byte[] data = new byte[length];
random.nextBytes(data);
return data;
}
public static byte[] toBytes(char[] chars) {
CharBuffer charBuf = CharBuffer.wrap(chars);
ByteBuffer byteBuf = StandardCharsets.UTF_8.encode(charBuf);
byte[] bytes = new byte[byteBuf.limit()];
byteBuf.get(bytes);
return bytes;
}
@Deprecated
public static byte[] toBytesOld(char[] chars) {
CharBuffer charBuf = CharBuffer.wrap(chars);
ByteBuffer byteBuf = StandardCharsets.UTF_8.encode(charBuf);
return byteBuf.array();
}
}

View file

@ -0,0 +1,122 @@
package com.beemdevelopment.aegis.crypto;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.ProviderException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.Collections;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
public class KeyStoreHandle {
private final KeyStore _keyStore;
private static final String STORE_NAME = "AndroidKeyStore";
public KeyStoreHandle() throws KeyStoreHandleException {
try {
_keyStore = KeyStore.getInstance(STORE_NAME);
_keyStore.load(null);
} catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) {
throw new KeyStoreHandleException(e);
}
}
public boolean containsKey(String id) throws KeyStoreHandleException {
try {
return _keyStore.containsAlias(id);
} catch (KeyStoreException e) {
throw new KeyStoreHandleException(e);
}
}
public SecretKey generateKey(String id) throws KeyStoreHandleException {
try {
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, STORE_NAME);
generator.init(new KeyGenParameterSpec.Builder(id,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setRandomizedEncryptionRequired(true)
.setKeySize(CryptoUtils.CRYPTO_AEAD_KEY_SIZE * 8)
.build());
return generator.generateKey();
} catch (ProviderException e) {
// a ProviderException can occur at runtime with buggy Keymaster HAL implementations
// so if this was caused by an android.security.KeyStoreException, throw a KeyStoreHandleException instead
Throwable cause = e.getCause();
if (cause != null && cause.getClass().getName().equals("android.security.KeyStoreException")) {
throw new KeyStoreHandleException(cause);
}
throw e;
} catch (NoSuchAlgorithmException | NoSuchProviderException | InvalidAlgorithmParameterException e) {
throw new KeyStoreHandleException(e);
}
}
public SecretKey getKey(String id) throws KeyStoreHandleException {
SecretKey key;
try {
key = (SecretKey) _keyStore.getKey(id, null);
} catch (UnrecoverableKeyException e) {
return null;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (KeyStoreException e) {
throw new KeyStoreHandleException(e);
}
if (isKeyPermanentlyInvalidated(key)) {
return null;
}
return key;
}
private static boolean isKeyPermanentlyInvalidated(SecretKey key) {
// try to initialize a dummy cipher and see if an InvalidKeyException is thrown
try {
Cipher cipher = Cipher.getInstance(CryptoUtils.CRYPTO_AEAD);
cipher.init(Cipher.ENCRYPT_MODE, key);
} catch (InvalidKeyException e) {
// some devices throw a plain InvalidKeyException, not KeyPermanentlyInvalidatedException
return true;
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new RuntimeException(e);
}
return false;
}
public void deleteKey(String id) throws KeyStoreHandleException {
try {
_keyStore.deleteEntry(id);
} catch (KeyStoreException e) {
throw new KeyStoreHandleException(e);
}
}
public void clear() throws KeyStoreHandleException {
try {
for (String alias : Collections.list(_keyStore.aliases())) {
deleteKey(alias);
}
} catch (KeyStoreException e) {
throw new KeyStoreHandleException(e);
}
}
}

View file

@ -0,0 +1,11 @@
package com.beemdevelopment.aegis.crypto;
public class KeyStoreHandleException extends Exception {
public KeyStoreHandleException(Throwable cause) {
super(cause);
}
public KeyStoreHandleException(String message) {
super(message);
}
}

View file

@ -0,0 +1,61 @@
package com.beemdevelopment.aegis.crypto;
import java.io.IOException;
import java.io.Serializable;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
public class MasterKey implements Serializable {
private SecretKey _key;
public MasterKey(SecretKey key) {
if (key == null) {
throw new IllegalArgumentException("Key cannot be null");
}
_key = key;
}
public static MasterKey generate() {
return new MasterKey(CryptoUtils.generateKey());
}
public CryptResult encrypt(byte[] bytes) throws MasterKeyException {
try {
Cipher cipher = CryptoUtils.createEncryptCipher(_key);
return CryptoUtils.encrypt(bytes, cipher);
} catch (NoSuchPaddingException
| NoSuchAlgorithmException
| InvalidAlgorithmParameterException
| InvalidKeyException
| BadPaddingException
| IllegalBlockSizeException e) {
throw new MasterKeyException(e);
}
}
public CryptResult decrypt(byte[] bytes, CryptParameters params) throws MasterKeyException {
try {
Cipher cipher = CryptoUtils.createDecryptCipher(_key, params.getNonce());
return CryptoUtils.decrypt(bytes, cipher, params);
} catch (NoSuchPaddingException
| NoSuchAlgorithmException
| InvalidAlgorithmParameterException
| InvalidKeyException
| BadPaddingException
| IOException
| IllegalBlockSizeException e) {
throw new MasterKeyException(e);
}
}
public byte[] getBytes() {
return _key.getEncoded();
}
}

View file

@ -0,0 +1,7 @@
package com.beemdevelopment.aegis.crypto;
public class MasterKeyException extends Exception {
public MasterKeyException(Throwable cause) {
super(cause);
}
}

View file

@ -0,0 +1,33 @@
package com.beemdevelopment.aegis.crypto;
import java.io.Serializable;
public class SCryptParameters implements Serializable {
private int _n;
private int _r;
private int _p;
private byte[] _salt;
public SCryptParameters(int n, int r, int p, byte[] salt) {
_n = n;
_r = r;
_p = p;
_salt = salt;
}
public byte[] getSalt() {
return _salt;
}
public int getN() {
return _n;
}
public int getR() {
return _r;
}
public int getP() {
return _p;
}
}

View file

@ -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 &gt;= 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);
}
}

View file

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

View file

@ -0,0 +1,45 @@
package com.beemdevelopment.aegis.crypto.otp;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class HOTP {
private HOTP() {
}
public static OTP generateOTP(byte[] secret, String algo, int digits, long counter)
throws NoSuchAlgorithmException, InvalidKeyException {
byte[] hash = getHash(secret, algo, counter);
// truncate hash to get the HTOP value
// http://tools.ietf.org/html/rfc4226#section-5.4
int offset = hash[hash.length - 1] & 0xf;
int otp = ((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8)
| (hash[offset + 3] & 0xff);
return new OTP(otp, digits);
}
public static byte[] getHash(byte[] secret, String algo, long counter)
throws NoSuchAlgorithmException, InvalidKeyException {
SecretKeySpec key = new SecretKeySpec(secret, "RAW");
// encode counter in big endian
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);
}
}

View file

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

View file

@ -0,0 +1,50 @@
package com.beemdevelopment.aegis.crypto.otp;
import androidx.annotation.NonNull;
public class OTP {
private static final String STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY";
private final int _code;
private final int _digits;
public OTP(int code, int digits) {
_code = code;
_digits = digits;
}
public int getCode() {
return _code;
}
public int getDigits() {
return _digits;
}
@NonNull
@Override
public String toString() {
int code = _code % (int) Math.pow(10, _digits);
// prepend zeroes if needed
StringBuilder res = new StringBuilder(Long.toString(code));
while (res.length() < _digits) {
res.insert(0, "0");
}
return res.toString();
}
public String toSteamString() {
int code = _code;
StringBuilder res = new StringBuilder();
for (int i = 0; i < _digits; i++) {
char c = STEAM_ALPHABET.charAt(code % STEAM_ALPHABET.length());
res.append(c);
code /= STEAM_ALPHABET.length();
}
return res.toString();
}
}

View file

@ -0,0 +1,21 @@
package com.beemdevelopment.aegis.crypto.otp;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class TOTP {
private TOTP() {
}
public static OTP generateOTP(byte[] secret, String algo, int digits, long period, long seconds)
throws InvalidKeyException, NoSuchAlgorithmException {
long counter = (long) Math.floor((double) seconds / period);
return HOTP.generateOTP(secret, algo, digits, counter);
}
public static OTP generateOTP(byte[] secret, String algo, int digits, long period)
throws InvalidKeyException, NoSuchAlgorithmException {
return generateOTP(secret, algo, digits, period, System.currentTimeMillis() / 1000);
}
}

View file

@ -0,0 +1,71 @@
package com.beemdevelopment.aegis.crypto.otp;
import androidx.annotation.NonNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
public class YAOTP {
private static final int EN_ALPHABET_LENGTH = 26;
private final long _code;
private final int _digits;
private YAOTP(long code, int digits) {
_code = code;
_digits = digits;
}
public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period)
throws NoSuchAlgorithmException, InvalidKeyException, IOException {
long seconds = System.currentTimeMillis() / 1000;
return generateOTP(secret, pin, digits, otpAlgo, period, seconds);
}
public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period, long seconds)
throws NoSuchAlgorithmException, InvalidKeyException, IOException {
byte[] pinWithHash;
byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8);
try (ByteArrayOutputStream stream = new ByteArrayOutputStream(pinBytes.length + secret.length)) {
stream.write(pinBytes);
stream.write(secret);
pinWithHash = stream.toByteArray();
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] keyHash = md.digest(pinWithHash);
if (keyHash[0] == 0) {
keyHash = Arrays.copyOfRange(keyHash, 1, keyHash.length);
}
long counter = (long) Math.floor((double) seconds / period);
byte[] periodHash = HOTP.getHash(keyHash, otpAlgo, counter);
int offset = periodHash[periodHash.length - 1] & 0xf;
periodHash[offset] &= 0x7f;
long otp = ByteBuffer.wrap(periodHash)
.order(ByteOrder.BIG_ENDIAN)
.getLong(offset);
return new YAOTP(otp, digits);
}
@NonNull
@Override
public String toString() {
long code = _code % (long) Math.pow(EN_ALPHABET_LENGTH, _digits);
char[] chars = new char[_digits];
for (int i = _digits - 1; i >= 0; i--) {
chars[i] = (char) ('a' + (code % EN_ALPHABET_LENGTH));
code /= EN_ALPHABET_LENGTH;
}
return new String(chars);
}
}

View file

@ -0,0 +1,14 @@
package com.beemdevelopment.aegis.crypto.pins;
import info.guardianproject.trustedintents.ApkSignaturePin;
public final class GuardianProjectFDroidRSA2048 extends ApkSignaturePin {
public GuardianProjectFDroidRSA2048() {
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},};
}
}

View file

@ -0,0 +1,15 @@
package com.beemdevelopment.aegis.database;
import android.content.Context;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Database(entities = {AuditLogEntry.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract AuditLogDao auditLogDao();
}

View file

@ -0,0 +1,17 @@
package com.beemdevelopment.aegis.database;
import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import java.util.List;
@Dao
public interface AuditLogDao {
@Insert
void insert(AuditLogEntry log);
@Query("SELECT * FROM audit_logs WHERE timestamp >= strftime('%s', 'now', '-30 days') ORDER BY timestamp DESC")
LiveData<List<AuditLogEntry>> getAll();
}

View file

@ -0,0 +1,61 @@
package com.beemdevelopment.aegis.database;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import com.beemdevelopment.aegis.EventType;
@Entity(tableName = "audit_logs")
public class AuditLogEntry {
@PrimaryKey(autoGenerate = true)
protected long id;
@NonNull
@ColumnInfo(name = "event_type")
private final EventType _eventType;
@ColumnInfo(name = "reference")
private final String _reference;
@ColumnInfo(name = "timestamp")
private final long _timestamp;
@Ignore
public AuditLogEntry(@NonNull EventType eventType) {
this(eventType, null);
}
@Ignore
public AuditLogEntry(@NonNull EventType eventType, @Nullable String reference) {
_eventType = eventType;
_reference = reference;
_timestamp = System.currentTimeMillis();
}
AuditLogEntry(long id, @NonNull EventType eventType, @Nullable String reference, long timestamp) {
this.id = id;
_eventType = eventType;
_reference = reference;
_timestamp = timestamp;
}
public long getId() {
return id;
}
public EventType getEventType() {
return _eventType;
}
public String getReference() {
return _reference;
}
public long getTimestamp() {
return _timestamp;
}
}

View file

@ -0,0 +1,66 @@
package com.beemdevelopment.aegis.database;
import androidx.lifecycle.LiveData;
import com.beemdevelopment.aegis.EventType;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AuditLogRepository {
private final AuditLogDao _auditLogDao;
private final Executor _executor;
public AuditLogRepository(AuditLogDao auditLogDao) {
_auditLogDao = auditLogDao;
_executor = Executors.newSingleThreadExecutor();
}
public LiveData<List<AuditLogEntry>> getAllAuditLogEntries() {
return _auditLogDao.getAll();
}
public void addVaultUnlockedEvent() {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCKED);
insert(auditLogEntry);
}
public void addBackupCreatedEvent() {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_BACKUP_CREATED);
insert(auditLogEntry);
}
public void addAndroidBackupCreatedEvent() {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_ANDROID_BACKUP_CREATED);
insert(auditLogEntry);
}
public void addVaultExportedEvent() {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_EXPORTED);
insert(auditLogEntry);
}
public void addEntrySharedEvent(String reference) {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.ENTRY_SHARED, reference);
insert(auditLogEntry);
}
public void addVaultUnlockFailedPasswordEvent() {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCK_FAILED_PASSWORD);
insert(auditLogEntry);
}
public void addVaultUnlockFailedBiometricsEvent() {
AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCK_FAILED_BIOMETRICS);
insert(auditLogEntry);
}
public void insert(AuditLogEntry auditLogEntry) {
_executor.execute(() -> {
_auditLogDao.insert(auditLogEntry);
});
}
}

View file

@ -0,0 +1,29 @@
package com.beemdevelopment.aegis.encoding;
import com.google.common.io.BaseEncoding;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
public class Base32 {
private Base32() {
}
public static byte[] decode(String s) throws EncodingException {
try {
return BaseEncoding.base32().decode(s.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new EncodingException(e);
}
}
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);
}
}

View file

@ -0,0 +1,27 @@
package com.beemdevelopment.aegis.encoding;
import com.google.common.io.BaseEncoding;
import java.nio.charset.StandardCharsets;
public class Base64 {
private Base64() {
}
public static byte[] decode(String s) throws EncodingException {
try {
return BaseEncoding.base64().decode(s);
} catch (IllegalArgumentException e) {
throw new EncodingException(e);
}
}
public static byte[] decode(byte[] s) throws EncodingException {
return decode(new String(s, StandardCharsets.UTF_8));
}
public static String encode(byte[] data) {
return BaseEncoding.base64().encode(data);
}
}

View file

@ -0,0 +1,13 @@
package com.beemdevelopment.aegis.encoding;
import java.io.IOException;
public class EncodingException extends IOException {
public EncodingException(Throwable cause) {
super(cause);
}
public EncodingException(String message) {
super(message);
}
}

View file

@ -0,0 +1,23 @@
package com.beemdevelopment.aegis.encoding;
import com.google.common.io.BaseEncoding;
import java.util.Locale;
public class Hex {
private Hex() {
}
public static byte[] decode(String s) throws EncodingException {
try {
return BaseEncoding.base16().decode(s.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new EncodingException(e);
}
}
public static String encode(byte[] data) {
return BaseEncoding.base16().lowerCase().encode(data);
}
}

View file

@ -0,0 +1,54 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import android.provider.Settings;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.animation.LayoutAnimationController;
public class AnimationsHelper {
private AnimationsHelper() {
}
public static Animation loadScaledAnimation(Context context, int animationResId) {
return loadScaledAnimation(context, animationResId, Scale.ANIMATOR);
}
public static Animation loadScaledAnimation(Context context, int animationResId, Scale scale) {
Animation animation = AnimationUtils.loadAnimation(context, animationResId);
long newDuration = (long) (animation.getDuration() * scale.getValue(context));
animation.setDuration(newDuration);
return animation;
}
public static LayoutAnimationController loadScaledLayoutAnimation(Context context, int animationResId) {
return loadScaledLayoutAnimation(context, animationResId, Scale.ANIMATOR);
}
public static LayoutAnimationController loadScaledLayoutAnimation(Context context, int animationResId, Scale scale) {
LayoutAnimationController controller = AnimationUtils.loadLayoutAnimation(context, animationResId);
Animation animation = controller.getAnimation();
animation.setDuration((long) (animation.getDuration() * scale.getValue(context)));
return controller;
}
public enum Scale {
ANIMATOR(Settings.Global.ANIMATOR_DURATION_SCALE),
TRANSITION(Settings.Global.TRANSITION_ANIMATION_SCALE);
private final String _setting;
Scale(String setting) {
_setting = setting;
}
public float getValue(Context context) {
return Settings.Global.getFloat(context.getContentResolver(), _setting, 1.0f);
}
public boolean isZero(Context context) {
return getValue(context) == 0;
}
}
}

View file

@ -0,0 +1,136 @@
package com.beemdevelopment.aegis.helpers;
import androidx.annotation.NonNull;
import androidx.biometric.BiometricPrompt;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import com.beemdevelopment.aegis.crypto.KeyStoreHandle;
import com.beemdevelopment.aegis.crypto.KeyStoreHandleException;
import com.beemdevelopment.aegis.vault.slots.BiometricSlot;
import com.beemdevelopment.aegis.vault.slots.Slot;
import com.beemdevelopment.aegis.vault.slots.SlotException;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
/**
* A class that can prepare initialization of a BiometricSlot by generating a new
* key in the Android KeyStore and authenticating a cipher for it through a
* BiometricPrompt.
*/
public class BiometricSlotInitializer extends BiometricPrompt.AuthenticationCallback {
private BiometricSlot _slot;
private Listener _listener;
private BiometricPrompt _prompt;
public BiometricSlotInitializer(Fragment fragment, Listener listener) {
_listener = listener;
_prompt = new BiometricPrompt(fragment, new UiThreadExecutor(), this);
}
public BiometricSlotInitializer(FragmentActivity activity, Listener listener) {
_listener = listener;
_prompt = new BiometricPrompt(activity, new UiThreadExecutor(), this);
}
/**
* Generates a new key in the Android KeyStore for the new BiometricSlot,
* initializes a cipher with it and shows a BiometricPrompt to the user for
* authentication. If authentication is successful, the new slot will be
* initialized and delivered back through the listener.
*/
public void authenticate(BiometricPrompt.PromptInfo info) {
if (_slot != null) {
throw new IllegalStateException("Biometric authentication already in progress");
}
KeyStoreHandle keyStore;
try {
keyStore = new KeyStoreHandle();
} catch (KeyStoreHandleException e) {
fail(e);
return;
}
// generate a new Android KeyStore key
// and assign it the UUID of the new slot as an alias
Cipher cipher;
BiometricSlot slot = new BiometricSlot();
try {
SecretKey key = keyStore.generateKey(slot.getUUID().toString());
cipher = Slot.createEncryptCipher(key);
} catch (KeyStoreHandleException | SlotException e) {
fail(e);
return;
}
_slot = slot;
_prompt.authenticate(info, new BiometricPrompt.CryptoObject(cipher));
}
/**
* Cancels the BiometricPrompt and resets the state of the initializer. It will
* also attempt to delete the previously generated Android KeyStore key.
*/
public void cancelAuthentication() {
if (_slot == null) {
throw new IllegalStateException("Biometric authentication not in progress");
}
reset();
_prompt.cancelAuthentication();
}
private void reset() {
if (_slot != null) {
try {
// clean up the unused KeyStore key
// this is non-critical, so just fail silently if an error occurs
String uuid = _slot.getUUID().toString();
KeyStoreHandle keyStore = new KeyStoreHandle();
if (keyStore.containsKey(uuid)) {
keyStore.deleteKey(uuid);
}
} catch (KeyStoreHandleException e) {
e.printStackTrace();
}
_slot = null;
}
}
private void fail(int errorCode, CharSequence errString) {
reset();
_listener.onSlotInitializationFailed(errorCode, errString);
}
private void fail(Exception e) {
e.printStackTrace();
fail(0, e.toString());
}
@Override
public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
super.onAuthenticationError(errorCode, errString);
fail(errorCode, errString.toString());
}
@Override
public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
_listener.onInitializeSlot(_slot, Objects.requireNonNull(result.getCryptoObject()).getCipher());
}
@Override
public void onAuthenticationFailed() {
super.onAuthenticationFailed();
}
public interface Listener {
void onInitializeSlot(BiometricSlot slot, Cipher cipher);
void onSlotInitializationFailed(int errorCode, @NonNull CharSequence errString);
}
}

View file

@ -0,0 +1,30 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt;
public class BiometricsHelper {
private BiometricsHelper() {
}
public static BiometricManager getManager(Context context) {
BiometricManager manager = BiometricManager.from(context);
if (manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) {
return manager;
}
return null;
}
public static boolean isCanceled(int errorCode) {
return errorCode == BiometricPrompt.ERROR_CANCELED
|| errorCode == BiometricPrompt.ERROR_USER_CANCELED
|| errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON;
}
public static boolean isAvailable(Context context) {
return getManager(context) != null;
}
}

View file

@ -0,0 +1,63 @@
package com.beemdevelopment.aegis.helpers;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import com.beemdevelopment.aegis.icons.IconType;
import com.beemdevelopment.aegis.vault.VaultEntryIcon;
import java.io.ByteArrayOutputStream;
import java.util.Objects;
public class BitmapHelper {
private BitmapHelper() {
}
/**
* Scales the given Bitmap to the given maximum width/height, while keeping the aspect ratio intact.
*/
public static Bitmap resize(Bitmap bitmap, int maxWidth, int maxHeight) {
if (maxHeight <= 0 || maxWidth <= 0) {
return bitmap;
}
float maxRatio = (float) maxWidth / maxHeight;
float ratio = (float) bitmap.getWidth() / bitmap.getHeight();
int width = maxWidth;
int height = maxHeight;
if (maxRatio > 1) {
width = (int) ((float) maxHeight * ratio);
} else {
height = (int) ((float) maxWidth / ratio);
}
return Bitmap.createScaledBitmap(bitmap, width, height, true);
}
public static boolean isVaultEntryIconOptimized(VaultEntryIcon icon) {
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
BitmapFactory.decodeByteArray(icon.getBytes(), 0, icon.getBytes().length, opts);
return opts.outWidth <= VaultEntryIcon.MAX_DIMENS && opts.outHeight <= VaultEntryIcon.MAX_DIMENS;
}
public static VaultEntryIcon toVaultEntryIcon(Bitmap bitmap, IconType iconType) {
if (bitmap.getWidth() > VaultEntryIcon.MAX_DIMENS
|| bitmap.getHeight() > VaultEntryIcon.MAX_DIMENS) {
bitmap = resize(bitmap, VaultEntryIcon.MAX_DIMENS, VaultEntryIcon.MAX_DIMENS);
}
ByteArrayOutputStream stream = new ByteArrayOutputStream();
if (Objects.equals(iconType, IconType.PNG)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
} else {
iconType = IconType.JPEG;
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
}
byte[] data = stream.toByteArray();
return new VaultEntryIcon(data, iconType);
}
}

View file

@ -0,0 +1,30 @@
package com.beemdevelopment.aegis.helpers;
import android.graphics.Rect;
import android.text.TextPaint;
import android.text.style.MetricAffectingSpan;
import androidx.annotation.NonNull;
public class CenterVerticalSpan extends MetricAffectingSpan {
Rect _substringBounds;
public CenterVerticalSpan(Rect substringBounds) {
_substringBounds = substringBounds;
}
@Override
public void updateMeasureState(@NonNull TextPaint textPaint) {
applyBaselineShift(textPaint);
}
@Override
public void updateDrawState(@NonNull TextPaint textPaint) {
applyBaselineShift(textPaint);
}
private void applyBaselineShift(TextPaint textPaint) {
float topDifference = textPaint.getFontMetrics().top - _substringBounds.top;
textPaint.baselineShift -= (topDifference / 2f);
}
}

View file

@ -0,0 +1,39 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import android.content.ContextWrapper;
import androidx.activity.ComponentActivity;
import androidx.annotation.NonNull;
import androidx.lifecycle.Lifecycle;
import javax.annotation.Nullable;
/**
* ContextHelper contains some disgusting hacks to obtain the Activity/Lifecycle from a Context.
*/
public class ContextHelper {
private ContextHelper() {
}
// source: https://github.com/androidx/androidx/blob/e32e1da51a0c7448c74861c667fa76738a415a89/mediarouter/mediarouter/src/main/java/androidx/mediarouter/app/MediaRouteButton.java#L425-L435
@Nullable
public static ComponentActivity getActivity(@NonNull Context context) {
while (context instanceof ContextWrapper) {
if (context instanceof ComponentActivity) {
return (ComponentActivity) context;
}
context = ((ContextWrapper) context).getBaseContext();
}
return null;
}
@Nullable
public static Lifecycle getLifecycle(@NonNull Context context) {
ComponentActivity activity = getActivity(context);
return activity == null ? null : activity.getLifecycle();
}
}

View file

@ -0,0 +1,27 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import android.widget.ArrayAdapter;
import android.widget.AutoCompleteTextView;
import androidx.annotation.ArrayRes;
import com.beemdevelopment.aegis.R;
import java.util.List;
public class DropdownHelper {
private DropdownHelper() {
}
public static void fillDropdown(Context context, AutoCompleteTextView dropdown, @ArrayRes int textArrayResId) {
ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(context, textArrayResId, R.layout.dropdown_list_item);
dropdown.setAdapter(adapter);
}
public static <T> void fillDropdown(Context context, AutoCompleteTextView dropdown, List<T> items) {
ArrayAdapter<T> adapter = new ArrayAdapter<>(context, R.layout.dropdown_list_item, items);
dropdown.setAdapter(adapter);
}
}

View file

@ -0,0 +1,24 @@
package com.beemdevelopment.aegis.helpers;
import android.text.Editable;
import android.widget.EditText;
import java.util.Arrays;
public class EditTextHelper {
private EditTextHelper() {
}
public static char[] getEditTextChars(EditText text) {
Editable editable = text.getText();
char[] chars = new char[editable.length()];
editable.getChars(0, editable.length(), chars, 0);
return chars;
}
public static boolean areEditTextsEqual(EditText text1, EditText text2) {
char[] password = getEditTextChars(text1);
char[] passwordConfirm = getEditTextChars(text2);
return password.length != 0 && Arrays.equals(password, passwordConfirm);
}
}

View file

@ -0,0 +1,57 @@
package com.beemdevelopment.aegis.helpers;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.view.View;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
public class FabScrollHelper {
private View _fabMenu;
private boolean _isAnimating;
public FabScrollHelper(View floatingActionsMenu) {
_fabMenu = floatingActionsMenu;
}
public void onScroll(int dx, int dy) {
if (dy > 2 && _fabMenu.getVisibility() == View.VISIBLE && !_isAnimating) {
setVisible(false);
} else if (dy < -2 && _fabMenu.getVisibility() != View.VISIBLE && !_isAnimating) {
setVisible(true);
}
}
public void setVisible(boolean visible) {
if (visible) {
_fabMenu.setVisibility(View.VISIBLE);
_fabMenu.animate()
.translationY(0)
.setInterpolator(new DecelerateInterpolator(2))
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
_isAnimating = false;
super.onAnimationEnd(animation);
}
}).start();
} else {
_isAnimating = true;
CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) _fabMenu.getLayoutParams();
int fabBottomMargin = lp.bottomMargin;
_fabMenu.animate()
.translationY(_fabMenu.getHeight() + fabBottomMargin)
.setInterpolator(new AccelerateInterpolator(2))
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
_isAnimating = false;
_fabMenu.setVisibility(View.INVISIBLE);
super.onAnimationEnd(animation);
}
}).start();
}
}
}

View file

@ -0,0 +1,39 @@
package com.beemdevelopment.aegis.helpers;
import androidx.recyclerview.widget.RecyclerView;
public interface ItemTouchHelperAdapter {
/**
* Called when an item has been dragged far enough to trigger a move. This is called every time
* an item is shifted, and <strong>not</strong> at the end of a "drop" event.<br/>
* <br/>
* Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after
* adjusting the underlying data to reflect this move.
*
* @param fromPosition The start position of the moved item.
* @param toPosition Then resolved position of the moved item.
* @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
* @see RecyclerView.ViewHolder#getAdapterPosition()
*/
void onItemMove(int fromPosition, int toPosition);
/**
* Called when an item has been dismissed by a swipe.<br/>
* <br/>
* Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after
* adjusting the underlying data to reflect this removal.
*
* @param position The position of the item dismissed.
* @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
* @see RecyclerView.ViewHolder#getAdapterPosition()
*/
void onItemDismiss(int position);
/**
* Called when an item has been dropped after a drag.
*
* @param position The position of the moved item.
*/
void onItemDrop(int position);
}

View file

@ -0,0 +1,14 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import android.util.DisplayMetrics;
public class MetricsHelper {
private MetricsHelper() {
}
public static int convertDpToPixels(Context context, float dp) {
return (int) (dp * (context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT));
}
}

View file

@ -0,0 +1,73 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.widget.EditText;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.beemdevelopment.aegis.R;
import com.google.android.material.textfield.TextInputLayout;
import com.google.common.base.Strings;
import com.nulabinc.zxcvbn.Strength;
import com.nulabinc.zxcvbn.Zxcvbn;
public class PasswordStrengthHelper {
// Limit the password length to prevent zxcvbn4j from exploding
private static final int MAX_PASSWORD_LENGTH = 64;
// Material design color palette
private final static String[] COLORS = {"#FF5252", "#FF5252", "#FFC107", "#8BC34A", "#4CAF50"};
private final Zxcvbn _zxcvbn = new Zxcvbn();
private final EditText _textPassword;
private final ProgressBar _barPasswordStrength;
private final TextView _textPasswordStrength;
private final TextInputLayout _textPasswordWrapper;
public PasswordStrengthHelper(
EditText textPassword,
ProgressBar barPasswordStrength,
TextView textPasswordStrength,
TextInputLayout textPasswordWrapper
) {
_textPassword = textPassword;
_barPasswordStrength = barPasswordStrength;
_textPasswordStrength = textPasswordStrength;
_textPasswordWrapper = textPasswordWrapper;
}
public void measure(Context context) {
if (_textPassword.getText().length() > MAX_PASSWORD_LENGTH) {
_barPasswordStrength.setProgress(0);
_textPasswordStrength.setText(R.string.password_strength_unknown);
} else {
Strength strength = _zxcvbn.measure(_textPassword.getText());
_barPasswordStrength.setProgress(strength.getScore());
_barPasswordStrength.setProgressTintList(ColorStateList.valueOf(Color.parseColor(getColor(strength.getScore()))));
_textPasswordStrength.setText((_textPassword.getText().length() != 0) ? getString(strength.getScore(), context) : "");
String warning = strength.getFeedback().getWarning();
_textPasswordWrapper.setError(warning);
_textPasswordWrapper.setErrorEnabled(!Strings.isNullOrEmpty(warning));
strength.wipe();
}
}
private static String getString(int score, Context context) {
if (score < 0 || score > 4) {
throw new IllegalArgumentException("Not a valid zxcvbn score");
}
String[] strings = context.getResources().getStringArray(R.array.password_strength);
return strings[score];
}
private static String getColor(int score) {
if (score < 0 || score > 4) {
throw new IllegalArgumentException("Not a valid zxcvbn score");
}
return COLORS[score];
}
}

View file

@ -0,0 +1,46 @@
package com.beemdevelopment.aegis.helpers;
import android.app.Activity;
import android.content.Context;
import android.content.pm.PackageManager;
import java.util.ArrayList;
import java.util.List;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
public class PermissionHelper {
private PermissionHelper() {
}
public static boolean granted(Context context, String permission) {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
}
public static boolean request(Activity activity, int requestCode, String... perms) {
List<String> deniedPerms = new ArrayList<>();
for (String permission : perms) {
if (!granted(activity, permission)) {
deniedPerms.add(permission);
}
}
int size = deniedPerms.size();
if (size > 0) {
String[] array = new String[size];
ActivityCompat.requestPermissions(activity, deniedPerms.toArray(array), requestCode);
}
return size == 0;
}
public static boolean checkResults(int[] grantResults) {
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,69 @@
package com.beemdevelopment.aegis.helpers;
import static android.graphics.ImageFormat.YUV_420_888;
import android.util.Log;
import android.util.Size;
import androidx.annotation.NonNull;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageProxy;
import com.google.zxing.NotFoundException;
import com.google.zxing.PlanarYUVLuminanceSource;
import com.google.zxing.Result;
import java.nio.ByteBuffer;
public class QrCodeAnalyzer implements ImageAnalysis.Analyzer {
private static final String TAG = QrCodeAnalyzer.class.getSimpleName();
public static final Size RESOLUTION = new Size(1200, 1600);
private final QrCodeAnalyzer.Listener _listener;
public QrCodeAnalyzer(QrCodeAnalyzer.Listener listener) {
_listener = listener;
}
@Override
public void analyze(@NonNull ImageProxy image) {
int format = image.getFormat();
if (format != YUV_420_888) {
Log.e(TAG, String.format("Unexpected YUV image format: %d", format));
image.close();
return;
}
ImageProxy.PlaneProxy plane = image.getPlanes()[0];
ByteBuffer buf = plane.getBuffer();
byte[] data = new byte[buf.remaining()];
buf.get(data);
buf.rewind();
PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(
data,
plane.getRowStride(),
image.getHeight(),
0,
0,
image.getWidth(),
image.getHeight(),
false
);
try {
Result result = QrCodeHelper.decodeFromSource(source);
if (_listener != null) {
_listener.onQrCodeDetected(result);
}
} catch (NotFoundException ignored) {
} finally {
image.close();
}
}
public interface Listener {
void onQrCodeDetected(Result result);
}
}

View file

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

View file

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

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