commit 3c8e58604eabea70aec3ab29ccddf03d0e1139ac Author: Fr4nz D13trich Date: Thu Dec 18 08:31:42 2025 +0100 Repo created diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4dd0941 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +buy_me_a_coffee: beemdevelopment +custom: + - "https://www.blockchain.com/btc/address/bc1q26kyxqjkc6tu477pzy0whagwhs4ypv93qls22n" + - "https://nanocrawler.cc/explorer/account/nano_1aegisc559b1x4p3839egnu579jkd4htpidy14eo9e31gzqmwuafypnj4q94" diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..1d2c7a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,89 @@ +name: Bug Report +description: Create a report to help us fix a bug +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Please read the [bug reports section of the contribution guidelines](https://github.com/beemdevelopment/Aegis/blob/master/CONTRIBUTING.md#bug-reports) before submitting an issue. + - type: input + id: version + attributes: + label: Version + description: Which version of Aegis are you using? + placeholder: "Example: v2.1" + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source + description: Where did you get Aegis from? + options: + - Google Play + - F-Droid + - GitHub + - Other + validations: + required: true + - type: dropdown + id: encryption + attributes: + label: Vault encryption + description: Do you have encryption enabled for your Aegis vault? + options: + - "Yes (with biometric unlock)" + - "Yes" + - "No" + validations: + required: true + - type: input + id: device + attributes: + label: Device + description: Which device are you using Aegis on? + placeholder: "Example: Pixel 5" + validations: + required: true + - type: input + id: android_version + attributes: + label: Android version + description: Which Android version is running on your device? + placeholder: "Example: Android 13" + validations: + required: true + - type: input + id: rom + attributes: + label: ROM + description: Are you using a custom ROM? If so, which one and which version? If you're using the stock OS that came with your device, you can leave this field empty. + placeholder: "Example: GrapheneOS" + validations: + required: false + - type: textarea + id: reproduction_steps + attributes: + label: Steps to reproduce + description: A detailed list of reproduction steps. + validations: + required: true + - type: textarea + id: expectations + attributes: + label: What do you expect to happen? + validations: + required: true + - type: textarea + id: reality + attributes: + label: What happens instead? + validations: + required: true + - type: textarea + id: log + attributes: + label: Log + description: If applicable, paste the debug log that you captured using ADB here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..2778851 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,6 @@ +--- +name: "Feature request" +about: "Suggest a new feature for this project" +labels: proposal +--- + diff --git a/.github/workflows/build-app-workflow.yaml b/.github/workflows/build-app-workflow.yaml new file mode 100644 index 0000000..ab511ab --- /dev/null +++ b/.github/workflows/build-app-workflow.yaml @@ -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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..b575535 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,42 @@ +name: codeql +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '25 16 * * 2' +jobs: + analyze: + name: analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + if: github.event_name != 'schedule' || github.repository == 'beemdevelopment/Aegis' + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Exclude paths + # The importers are excluded from analysis, because some of the apps Aegis + # can import from don't have such great crypto, which will cause false + # positive security alerts. + run: | + find app/src/main/java/com/beemdevelopment/aegis/importers ! \( -name AegisImporter.java -o -name "DatabaseImporter*" \) -type f -exec rm -f {} + + sed -i '/Importer.class/d' app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java + - uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'gradle' + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: java + - name: Build + run: ./gradlew assembleDebug + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 0000000..e789836 --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,25 @@ +name: crowdin +on: + push: + branches: + - master +# run sequentially (per branch) +concurrency: "crowdin-upload-${{ github.ref }}" +jobs: + upload-sources: + runs-on: ubuntu-latest + if: github.repository == 'beemdevelopment/Aegis' + steps: + - uses: actions/checkout@v4 + - name: Install crowdin-cli + run: | + wget https://github.com/crowdin/crowdin-cli/releases/download/4.6.1/crowdin-cli.zip + echo "7afd70de3a747ac631a5bad7866008163ae1d50c4606b5773f0b90a5481ffde2 crowdin-cli.zip" | sha256sum -c + unzip crowdin-cli.zip -d crowdin-cli + - name: Upload to Crowdin + env: + CROWDIN_PERSONAL_TOKEN: "${{ secrets.CROWDIN_TOKEN }}" + run: | + java -jar ./crowdin-cli/4.6.1/crowdin-cli.jar upload sources \ + --no-progress \ + --branch master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9bb113 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0c7733c --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..6d51457 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,109 @@ +# FAQ + +## General + +### How can I contribute? + +There are lots of ways! Please refer to our [contributing +guide](https://github.com/beemdevelopment/Aegis/blob/master/CONTRIBUTING.md). + +### Why is the latest version not on F-Droid yet? + +We don't release new versions of Aegis on F-Droid ourselves. Once we've released +a new version on GitHub, F-Droid will usually kick off their automatic build +process a day later and publish the app to their repository a couple of days +afterwards. It can sometimes take up to a week for a new version to appear on +F-Droid. + +### Can you port Aegis to iOS/Windows/MacOS/Browser Extension? + +We don't have plans to port Aegis to other platforms. + +### Can you add support for Autofill? + +On Android, only one app can be active in the Autofill slot at a time, and since +this is typically occupied by the password manager, we don't see much value in +adding support for this feature in Aegis. + +### What is the difference between exporting and backing up? + +Exporting is done manually and backups are done automatically. The format of the +vault file is exactly the same for both. + +## Security + +### I can no longer use biometrics to unlock the app. What should I do? + +If you could previously unlock Aegis with biometrics, but suddenly can't do so +anymore, this is probably caused by a change made to the security settings of +your device. The app will tell you when this happened in most cases. To resolve +this, unlock the app with your password, disable biometric unlock in the +settings of Aegis and re-enable it. + +### Why does Aegis keep prompting me for my password, even though I have enabled biometric authentication? + +You're probably encountering the password reminder. Try entering your password +to unlock the vault once. After that, Aegis will prompt for biometrics by +default again until it's time for another password reminder. + +Since forgetting your password will result in loss of access to the contents of +the vault, __we do NOT recommend disabling the password reminder__. + +### Aegis uses SHA1 for most/all of my tokens. Isn't that insecure? + +The hash algorithm is imposed by the service you're setting up 2FA for (e.g. +Google, Facebook, GitHub, etc). There is nothing we can do about that. If we +were to change this on Aegis' end, the tokens would stop working. Furthermore, +when using SHA1 in an HMAC calculation, the currently known issues in SHA1 are +not of concern. + +### Why doesn't Aegis support biometric unlock for my device, even though it works with other apps? + +The reason for this is pretty technical. In short, since you're not entering +your password when using biometric unlock, Aegis needs some other way to decrypt +the vault. For this purpose, we generate and use a key in the Android Keystore, +telling it to only allow us to use that key if the user authenticates using +their biometrics first. Some devices have buggy implementations of this feature, +resulting in the error displayed to you by Aegis in an error dialog. + +If biometrics works with other apps, but not with Aegis, that means those other +apps probably perform a weaker form of biometric authentication. + +## Backups + +### How can I back up my Aegis vault to the cloud automatically? + +Aegis can only automatically back up to the cloud if the app of your cloud +provider is installed on your device and fully participates in the Android +Storage Access Framework. Aegis doesn't have access to the internet and we don't +have plans to change this, so adding support for specific cloud providers in the +app is not possible. + +Cloud providers currently known to be supported: +- Nextcloud + +Another common setup is to configure Aegis to back up to a folder on local +storage of your device and then have a separate app (like +[Syncthing](https://syncthing.net/)) sync that folder anywhere you want. + +## Encrypted Backups + +### Why do I not get prompted to enter an encryption password when exporting? + +Aegis uses the same password you have configured to encrypt your vault as the +password which is used when exporting and importing your vault; so when prompted, +you will enter that when importing your vault. + +## Importing + +### When importing from Authenticator Plus, an error is shown claiming that Accounts.txt is missing + +Make sure you supply an Authenticator Plus export file obtained through +__Settings -> Backup & Restore -> Export as Text and HTML__. The ``.db`` format +is not supported. + +If it still doesn't work, please report the issue to us. As a temporary +workaround, you can try extracting the ZIP archive on a computer, recreating it +without a password and then importing that into Aegis. Another option is +extracting the ZIP archive on a computer and importing the resulting +Accounts.txt file into Aegis with the "Plain text" import option. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..195bd4e --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ + + +# Aegis Authenticator + +
+ +[![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 + +[Screenshot 1](metadata/en-US/images/phoneScreenshots/screenshot1.png?raw=true) +[Screenshot 2](metadata/en-US/images/phoneScreenshots/screenshot2.png?raw=true) +[Screenshot 3](metadata/en-US/images/phoneScreenshots/screenshot3.png?raw=true) +[Screenshot 4](metadata/en-US/images/phoneScreenshots/screenshot4.png?raw=true) + +[Screenshot 5](metadata/en-US/images/phoneScreenshots/screenshot5.png?raw=true) +[Screenshot 6](metadata/en-US/images/phoneScreenshots/screenshot6.png?raw=true) +[Screenshot 7](metadata/en-US/images/phoneScreenshots/screenshot7.png?raw=true) +[Screenshot 8](metadata/en-US/images/phoneScreenshots/screenshot8.png?raw=true) + +## Downloads + +Aegis is available on the Google Play Store and on F-Droid. + +[Get it on Google Play](http://play.google.com/store/apps/details?id=com.beemdevelopment.aegis) +[Get it on F-Droid](https://f-droid.org/app/com.beemdevelopment.aegis) + +### Verification + +APK releases on Google Play and GitHub are signed using the same key. They can +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. + + [aegis-icons preview](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. + + [delta-icons preview](https://github.com/Delta-Icons/aegis-icons) + +- [aegis-simple-icons](https://github.com/alexbakker/aegis-simple-icons) \* + + This project periodically generates an icon pack for Aegis based on [Simple + Icons](https://simpleicons.org/). + + [aegis-simple-icons preview](https://github.com/alexbakker/aegis-simple-icons) + +- [aegis-simple-icons-outlined](https://github.com/michaelschattgen/aegis-simple-icons-outlined) \* + + This is a variant on the aegis-simple-icons pack where the icons contain no solid background and just the outlines are being used. + + [aegis-simple-icons-outlined preview](https://github.com/michaelschattgen/aegis-simple-icons-outlined) + +\* The icons are automatically generated, so +not all of them are as high quality as the ones you'll find in +[aegis-icons](https://github.com/aegis-icons/aegis-icons). + +## Contributing + +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) diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..92e291f --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/config/libraries/krop.json b/app/config/libraries/krop.json new file mode 100644 index 0000000..72731ef --- /dev/null +++ b/app/config/libraries/krop.json @@ -0,0 +1,6 @@ +{ + "uniqueId": "com.github.avito-tech:krop", + "licenses": [ + "MIT" + ] +} \ No newline at end of file diff --git a/app/config/libraries/libsu.json b/app/config/libraries/libsu.json new file mode 100644 index 0000000..3ec91d8 --- /dev/null +++ b/app/config/libraries/libsu.json @@ -0,0 +1,6 @@ +{ + "uniqueId": "com.github.topjohnwu.libsu:.*::regex", + "licenses": [ + "Apache-2.0" + ] +} \ No newline at end of file diff --git a/app/config/libraries/textdrawable.json b/app/config/libraries/textdrawable.json new file mode 100644 index 0000000..528a635 --- /dev/null +++ b/app/config/libraries/textdrawable.json @@ -0,0 +1,15 @@ +{ + "uniqueId": "com.amulyakhare:com.amulyakhare.textdrawable", + "funding": [ + + ], + "developers": [ + + ], + "artifactVersion": "1.0.1", + "description": "This light-weight library provides images with letter/text like the Gmail app. It extends the Drawable class thus can be used with existing/custom/network ImageView classes. Also included is a fluent interface for creating drawables and a customizable ColorGenerator.", + "name": "textdrawable", + "licenses": [ + "MIT" + ] +} \ No newline at end of file diff --git a/app/config/libraries/trustedintents.json b/app/config/libraries/trustedintents.json new file mode 100644 index 0000000..5ba8906 --- /dev/null +++ b/app/config/libraries/trustedintents.json @@ -0,0 +1,23 @@ +{ + "uniqueId": "info.guardianproject.trustedintents:trustedintents", + "funding": [ + + ], + "developers": [ + { + "name": "Guardian Project" + } + ], + "artifactVersion": "0.2", + "description": "TrustedIntents is a library for flexible trusted interactions between Android apps. It is modeled after Android's `signature` protection level for permissions. The key difference is that the framework allows the trusted signature to be set, rather than requiring to match the current app's signature.", + "scm": { + "connection": "scm:https://github.com/guardianproject/TrustedIntents.git", + "url": "scm:https://github.com/guardianproject/TrustedIntents", + "developerConnection": "scm:git@github.com:guardianproject/TrustedIntents.git" + }, + "name": "TrustedIntents", + "website": "https://guardianproject.info/code/trustedintents", + "licenses": [ + "3ca920d1875f7ad7ab04a2a331958577" + ] +} \ No newline at end of file diff --git a/app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json b/app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json new file mode 100644 index 0000000..2f0d7c2 --- /dev/null +++ b/app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json @@ -0,0 +1,5 @@ +{ + "hash": "3ca920d1875f7ad7ab04a2a331958577", + "url": "https://github.com/guardianproject/TrustedIntents/blob/master/LICENSE.txt", + "name": "LGPLv2.1" +} \ No newline at end of file diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 0000000..f9f30d6 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f078932 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** diff --git a/app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json b/app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json new file mode 100644 index 0000000..811e430 --- /dev/null +++ b/app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json @@ -0,0 +1,52 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "392278bdb797d013cb2ada67a3b1cc60", + "entities": [ + { + "tableName": "audit_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `event_type` TEXT NOT NULL, `reference` TEXT, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_eventType", + "columnName": "event_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "_reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "_timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '392278bdb797d013cb2ada67a3b1cc60')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java new file mode 100644 index 0000000..a7322c3 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java @@ -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 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 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 VaultEntry generateEntry(Class type, String name, String issuer) { + return generateEntry(type, name, issuer, 20); + } + + protected static VaultEntry generateEntry(Class type, String name, String issuer, int secretLength) { + byte[] secret = CryptoUtils.generateRandomBytes(secretLength); + + OtpInfo info; + try { + 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 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 withOtpType(Class otpClass) { + return new BoundedMatcher(EntryHolder.class) { + @Override + public boolean matchesSafely(EntryHolder holder) { + return holder != null + && holder.getEntry() != null + && holder.getEntry().getInfo().getClass().equals(otpClass); + } + + @Override + public void describeTo(Description description) { + description.appendText(String.format("with otp type '%s'", otpClass.getSimpleName())); + } + }; + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java new file mode 100644 index 0000000..62f5b7f --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis; + +import dagger.hilt.android.testing.CustomTestApplication; + +@CustomTestApplication(AegisApplicationBase.class) +public interface AegisTestApplication { +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java new file mode 100644 index 0000000..970f9ff --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java @@ -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); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java new file mode 100644 index 0000000..e015139 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java @@ -0,0 +1,431 @@ +package com.beemdevelopment.aegis; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.pressBack; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.Nullable; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.espresso.intent.Intents; +import androidx.test.espresso.matcher.RootMatchers; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.crypto.MasterKey; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.importers.DatabaseImporter; +import com.beemdevelopment.aegis.importers.DatabaseImporterException; +import com.beemdevelopment.aegis.importers.GoogleAuthUriImporter; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.rules.ScreenshotTestRule; +import com.beemdevelopment.aegis.ui.PreferencesActivity; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultBackupManager; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultFileCredentials; +import com.beemdevelopment.aegis.vault.VaultFileException; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; +import com.beemdevelopment.aegis.vault.slots.SlotException; +import com.beemdevelopment.aegis.vault.slots.SlotIntegrityException; +import com.beemdevelopment.aegis.vault.slots.SlotList; +import com.beemdevelopment.aegis.vectors.VaultEntries; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; + +import dagger.hilt.android.testing.HiltAndroidTest; + +@RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@SmallTest +public class BackupExportTest extends AegisTest { + private final ActivityScenarioRule _activityRule = new ActivityScenarioRule<>(PreferencesActivity.class); + + @Rule + public final TestRule testRule = RuleChain.outerRule(_activityRule).around(new ScreenshotTestRule()); + + @Before + public void setUp() { + Intents.init(); + } + + @After + public void tearDown() { + Intents.release(); + } + + @Test + public void testPlainVaultExportPlainJson() { + initPlainVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readVault(file, null); + } + + @Test + public void testPlainVaultExportPlainTxt() { + initPlainVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_google_auth_uri)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readTxtExport(file); + } + + @Test + public void testPlainVaultExportEncryptedJson() { + initPlainVault(); + + openExportDialog(); + File file = doExport(); + + onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + + readVault(file, VAULT_PASSWORD); + } + + @Test + public void testEncryptedVaultExportPlainJson() { + initEncryptedVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readVault(file, null); + } + + @Test + public void testEncryptedVaultExportPlainTxt() { + initEncryptedVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_google_auth_uri)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + readTxtExport(file); + } + + @Test + public void testEncryptedVaultExportEncryptedJson() { + initEncryptedVault(); + + openExportDialog(); + File file = doExport(); + + readVault(file, VAULT_PASSWORD); + } + + @Test + public void testPlainVaultExportHtml() { + initPlainVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_html)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + checkHtmlExport(file); + } + + @Test + public void testEncryptedVaultExportHtml() { + initEncryptedVault(); + + openExportDialog(); + onView(withId(R.id.checkbox_export_encrypt)).perform(click()); + onView(withId(R.id.dropdown_export_format)).perform(click()); + onView(withText(R.string.export_format_html)).inRoot(RootMatchers.isPlatformPopup()).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + onView(withId(R.id.checkbox_accept)).perform(click()); + File file = doExport(); + + checkHtmlExport(file); + } + + @Test + public void testSeparateExportPassword() { + initEncryptedVault(); + setSeparateBackupExportPassword(); + + openExportDialog(); + File file = doExport(); + + readVault(file, VAULT_BACKUP_PASSWORD); + } + + @Test + public void testChangeBackupPassword() throws SlotIntegrityException { + initEncryptedVault(); + setSeparateBackupExportPassword(); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_backup_password_change_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(VAULT_BACKUP_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_BACKUP_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + + VaultFileCredentials creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1); + + for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) { + verifyPasswordSlotChange(creds, slot, VAULT_BACKUP_PASSWORD, VAULT_BACKUP_PASSWORD_CHANGED); + } + + for (PasswordSlot slot : creds.getSlots().findRegularPasswordSlots()) { + decryptPasswordSlot(slot, VAULT_PASSWORD); + } + + openExportDialog(); + File file = doExport(); + readVault(file, VAULT_BACKUP_PASSWORD_CHANGED); + } + + @Test + public void testChangePasswordHavingBackupPassword() throws SlotIntegrityException { + initEncryptedVault(); + setSeparateBackupExportPassword(); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_set_password_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(VAULT_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_PASSWORD_CHANGED), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + + VaultFileCredentials creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1); + + for (PasswordSlot slot : creds.getSlots().findRegularPasswordSlots()) { + verifyPasswordSlotChange(creds, slot, VAULT_PASSWORD, VAULT_PASSWORD_CHANGED); + } + + for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) { + decryptPasswordSlot(slot, VAULT_BACKUP_PASSWORD); + } + + openExportDialog(); + File file = doExport(); + readVault(file, VAULT_BACKUP_PASSWORD); + } + + private void setSeparateBackupExportPassword() { + VaultFileCredentials creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 0); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_security_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_backup_password_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(VAULT_BACKUP_PASSWORD), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(VAULT_BACKUP_PASSWORD), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + + creds = _vaultManager.getVault().getCredentials(); + assertEquals(creds.getSlots().findRegularPasswordSlots().size(), 1); + assertEquals(creds.getSlots().findBackupPasswordSlots().size(), 1); + for (PasswordSlot slot : creds.getSlots().findBackupPasswordSlots()) { + verifyPasswordSlotChange(creds, slot, VAULT_PASSWORD, VAULT_BACKUP_PASSWORD); + } + } + + private void verifyPasswordSlotChange(VaultFileCredentials creds, PasswordSlot slot, String oldPassword, String newPassword) { + assertThrows(SlotIntegrityException.class, () -> decryptPasswordSlot(slot, oldPassword)); + MasterKey masterKey; + try { + masterKey = decryptPasswordSlot(slot, newPassword); + } catch (SlotIntegrityException e) { + throw new RuntimeException("Unable to decrypt password slot", e); + } + + assertArrayEquals(creds.getKey().getBytes(), masterKey.getBytes()); + } + + private File doExport() { + File file = getExportFileUri(); + Intent resultData = new Intent(); + resultData.setData(Uri.fromFile(file)); + + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + intending(not(isInternal())).respondWith(result); + + onView(withId(android.R.id.button1)).perform(click()); + return file; + } + + private void openExportDialog() { + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_section_import_export_title)), click())); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_export_title)), click())); + } + + private MasterKey decryptPasswordSlot(PasswordSlot slot, String password) throws SlotIntegrityException { + SecretKey derivedKey = slot.deriveKey(password.toCharArray()); + try { + Cipher cipher = slot.createDecryptCipher(derivedKey); + return slot.getKey(cipher); + } catch (SlotException e) { + throw new RuntimeException("Unable to decrypt password slot", e); + } + } + + private File getExportFileUri() { + String dirName = Hex.encode(CryptoUtils.generateRandomBytes(8)); + File dir = new File(getInstrumentation().getTargetContext().getExternalCacheDir(), String.format("export-%s", dirName)); + if (!dir.mkdirs()) { + throw new RuntimeException(String.format("Unable to create export directory: %s", dir)); + } + + VaultBackupManager.FileInfo fileInfo = new VaultBackupManager.FileInfo(VaultRepository.FILENAME_PREFIX_EXPORT); + return new File(dir, fileInfo.toString()); + } + + private VaultRepository readVault(File file, @Nullable String password) { + VaultRepository repo; + try (InputStream inStream = new FileInputStream(file)) { + byte[] bytes = IOUtils.readAll(inStream); + VaultFile vaultFile = VaultFile.fromBytes(bytes); + + VaultFileCredentials creds = null; + if (password != null) { + SlotList slots = vaultFile.getHeader().getSlots(); + for (PasswordSlot slot : slots.findAll(PasswordSlot.class)) { + SecretKey derivedKey = slot.deriveKey(password.toCharArray()); + Cipher cipher = slot.createDecryptCipher(derivedKey); + MasterKey masterKey = slot.getKey(cipher); + creds = new VaultFileCredentials(masterKey, slots); + break; + } + } + + repo = VaultRepository.fromFile(getInstrumentation().getContext(), vaultFile, creds); + } catch (SlotException | SlotIntegrityException | VaultRepositoryException | VaultFileException | IOException e) { + throw new RuntimeException("Unable to read back vault file", e); + } + + checkReadEntries(repo.getEntries()); + return repo; + } + + private void readTxtExport(File file) { + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(getInstrumentation().getContext()); + + Collection entries; + try (InputStream inStream = new FileInputStream(file)) { + DatabaseImporter.State state = importer.read(inStream); + DatabaseImporter.Result result = state.convert(); + entries = result.getEntries().getValues(); + } catch (DatabaseImporterException | IOException e) { + throw new RuntimeException("Unable to read txt export file", e); + } + + checkReadEntries(entries); + } + + private void checkHtmlExport(File file) { + try (InputStream inStream = new FileInputStream(file)) { + Reader inReader = new InputStreamReader(inStream, StandardCharsets.UTF_8); + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + XmlPullParser parser = factory.newPullParser(); + parser.setInput(inReader); + while (parser.getEventType() != XmlPullParser.START_TAG) { + parser.next(); + } + if (!parser.getName().toLowerCase(Locale.ROOT).equals("html")) { + throw new RuntimeException("not an html document!"); + } + while (parser.getEventType() != XmlPullParser.END_DOCUMENT) { + parser.next(); + } + } catch (IOException | XmlPullParserException e) { + throw new RuntimeException("Unable to read html export file", e); + } + } + + private void checkReadEntries(Collection entries) { + List vectors = VaultEntries.get(); + assertEquals(vectors.size(), entries.size()); + + int i = 0; + for (VaultEntry entry : entries) { + VaultEntry vector = vectors.get(i); + String message = String.format("Entries are not equivalent: (%s) (%s)", vector.toJson().toString(), entry.toJson().toString()); + assertTrue(message, vector.equivalates(entry)); + try { + assertEquals(message, vector.getInfo().getOtp(), entry.getInfo().getOtp()); + } catch (OtpInfoException e) { + throw new RuntimeException("Unable to generate OTP", e); + } + i++; + } + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java new file mode 100644 index 0000000..2afefaa --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java @@ -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 rule = new ActivityTestRule<>(MainActivity.class); + rule.launchActivity(intent); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java new file mode 100644 index 0000000..47c5323 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java @@ -0,0 +1,54 @@ +package com.beemdevelopment.aegis; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.rules.ScreenshotTestRule; +import com.beemdevelopment.aegis.ui.MainActivity; +import com.beemdevelopment.aegis.vault.VaultEntry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; + +import dagger.hilt.android.testing.HiltAndroidTest; + +@RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@SmallTest +public class EmptySecretTest extends AegisTest { + private ActivityScenario _scenario; + + @Before + public void before() throws OtpInfoException { + initEmptyPlainVault(); + _vaultManager.getVault().addEntry(new VaultEntry(new TotpInfo(new byte[0]))); + + _scenario = ActivityScenario.launch(MainActivity.class); + } + + @After + public void after() { + _scenario.close(); + } + + @Test + public void testVaultEntryEmptySecret() { + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.error_all_caps)), click())); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java new file mode 100644 index 0000000..ad9d5a6 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java @@ -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 _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; + } + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java new file mode 100644 index 0000000..016eb5c --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java @@ -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 _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 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 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()); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java new file mode 100644 index 0000000..39feca5 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java @@ -0,0 +1,58 @@ +package com.beemdevelopment.aegis; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import android.content.Intent; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; +import androidx.test.rule.ActivityTestRule; + +import com.beemdevelopment.aegis.ui.PanicResponderActivity; +import com.beemdevelopment.aegis.vault.VaultRepository; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import dagger.hilt.android.testing.HiltAndroidTest; + +@RunWith(AndroidJUnit4.class) +@HiltAndroidTest +@SmallTest +public class PanicTriggerTest extends AegisTest { + @Before + public void before() { + initEncryptedVault(); + } + + @Test + public void testPanicTriggerDisabled() { + assertFalse(_prefs.isPanicTriggerEnabled()); + assertTrue(_vaultManager.isVaultLoaded()); + launchPanic(); + assertTrue(_vaultManager.isVaultLoaded()); + _vaultManager.getVault(); + assertTrue(VaultRepository.fileExists(getApp())); + } + + @Test + public void testPanicTriggerEnabled() { + _prefs.setIsPanicTriggerEnabled(true); + assertTrue(_prefs.isPanicTriggerEnabled()); + assertTrue(_vaultManager.isVaultLoaded()); + launchPanic(); + assertFalse(_vaultManager.isVaultLoaded()); + assertThrows(IllegalStateException.class, () -> _vaultManager.getVault()); + assertFalse(VaultRepository.fileExists(getApp())); + } + + private void launchPanic() { + Intent intent = new Intent(PanicResponderActivity.PANIC_TRIGGER_ACTION); + // we need to use the deprecated ActivityTestRule class because of https://github.com/android/android-test/issues/143 + ActivityTestRule rule = new ActivityTestRule<>(PanicResponderActivity.class); + rule.launchActivity(intent); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java b/app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java new file mode 100644 index 0000000..613394e --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java @@ -0,0 +1,36 @@ +package com.beemdevelopment.aegis.rules; + +import android.graphics.Bitmap; + +import androidx.test.runner.screenshot.BasicScreenCaptureProcessor; +import androidx.test.runner.screenshot.ScreenCapture; +import androidx.test.runner.screenshot.ScreenCaptureProcessor; +import androidx.test.runner.screenshot.Screenshot; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import java.io.IOException; +import java.util.HashSet; + +public class ScreenshotTestRule extends TestWatcher { + @Override + protected void failed(Throwable e, Description description) { + super.failed(e, description); + + String filename = description.getTestClass().getSimpleName() + "-" + description.getMethodName(); + + ScreenCapture capture = Screenshot.capture(); + capture.setName(filename); + capture.setFormat(Bitmap.CompressFormat.PNG); + + HashSet processors = new HashSet<>(); + processors.add(new BasicScreenCaptureProcessor()); + + try { + capture.process(processors); + } catch (IOException e2) { + e.printStackTrace(); + } + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java new file mode 100644 index 0000000..ee32feb --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java @@ -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); + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/vectors/VaultEntries.java b/app/src/androidTest/java/com/beemdevelopment/aegis/vectors/VaultEntries.java new file mode 120000 index 0000000..f8cf5bb --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/vectors/VaultEntries.java @@ -0,0 +1 @@ +../../../../../../test/java/com/beemdevelopment/aegis/vectors/VaultEntries.java \ No newline at end of file diff --git a/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_encrypted.json b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_encrypted.json new file mode 120000 index 0000000..02a115f --- /dev/null +++ b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_encrypted.json @@ -0,0 +1 @@ +../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_encrypted.json \ No newline at end of file diff --git a/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_plain.json b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_plain.json new file mode 120000 index 0000000..b710d05 --- /dev/null +++ b/app/src/androidTest/resources/com/beemdevelopment/aegis/aegis_plain.json @@ -0,0 +1 @@ +../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_plain.json \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0933718 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/LICENSE b/app/src/main/assets/LICENSE new file mode 120000 index 0000000..1477615 --- /dev/null +++ b/app/src/main/assets/LICENSE @@ -0,0 +1 @@ +../../../../LICENSE \ No newline at end of file diff --git a/app/src/main/assets/changelog.html b/app/src/main/assets/changelog.html new file mode 100644 index 0000000..5226dd8 --- /dev/null +++ b/app/src/main/assets/changelog.html @@ -0,0 +1,549 @@ + + + + + +
+

Version 3.4.1

+

New

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

Fixes

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

Version 3.4

+

New

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

Fixes

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

Miscellaneous

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

Version 3.3.4

+

Fixes

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

Version 3.3.3

+

Fixes

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

Version 3.3.2

+

New

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

Fixes

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

Version 3.3.1

+

Fixes

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

Version 3.3

+

New

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

Fixes

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

Version 3.2

+

New

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

Fixes

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

Version 3.1.1

+

Fixes

+

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

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

Version 3.1

+

New

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

Fixes

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

Version 3.0.1

+

New

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

Fixes

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

Version 3.0

+

New

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

Fixes

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

Version 2.2.2

+

New

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

Fixes

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

Version 2.2.1

+

New

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

Fixes

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

Version 2.2

+

New

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

Fixes

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

Version 2.1.3

+

New

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

Fixes

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

Version 2.1.2

+

Fixes

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

Version 2.1.1

+

New

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

Fixes

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

Version 2.1

+

New

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

Fixes

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

Version 2.0.3

+

New

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

Version 2.0.2

+

New

+
    +
  • Add a note field to entries
  • +
  • An option to pause code updating of highlighted entries
  • +
  • New translation: Lithuanian
  • +
+

Fixes

+
    +
  • Minor UI and stability improvements
  • +
  • The Microsoft Authenticator importer did not accept spaces and dashes in secrets
  • +
+

Version 2.0.1

+

New

+
    +
  • Support for sorting on most used tokens
  • +
  • Some minor UX and stability improvements
  • +
  • New translation: Vietnamese
  • +
+

Fixes

+
    +
  • QR code information was decoded incorrectly in some cases if the app was set to a certain language (Turkish, for example)
  • +
+

Version 2.0

+

New

+
    +
  • Support for icon packs
  • +
  • Support for participation in Android's backup system (Google Drive, Seedvault)
  • +
  • UI refresh (switched to the Material Components theme)
  • +
  • Bottom sheet with chips to filter on groups
  • +
  • Support for importing from 2FAS Authenticator
  • +
  • Search in account names by default (and remove the setting)
  • +
  • Replaced the FAB with a bottom sheet dialog
  • +
  • Reorganization of settings into separate categories
  • +
  • Ability to 'share' images of QR codes to scan in Aegis
  • +
  • Option to save the current group filter
  • +
  • New translations for Bulgarian, Danish, Latvian, Swedish and Ukranian
  • +
+

Fixes

+
    +
  • The QR code scanner had trouble detecting QR codes on some devices due to low resolution image capture
  • +
  • The app would vanish from the recent apps list after locking
  • +
  • When importing from Nextcloud, Aegis would report that the file could not be found.
  • +
  • The biometrics prompt would not appear on some devices
  • +
  • The app would lock when selecting a file/icon on certain devices and configurations
  • +
  • There were multiple layout issues on small screen devices
  • +
  • Various other usability, performance and stability improvements
  • +
+

Version 1.4.2

+

Fixes

+
    +
  • The app would crash if DocumentsUI is not present on the device
  • +
  • The app would close when selecting an icon if auto lock on minimize was enabled
  • +
  • Importing from Authy was flaky for entries that have an icon
  • +
  • The dark theme was not properly applied to the QR code scanner view
  • +
  • The app would crash on plain text export on some devices
  • +
  • Importing from Authenticator Plus stopped working
  • +
+

Version 1.4.1

+

Fixes

+
    +
  • Scanning QR codes stopped working on certain devices (primarily OnePlus)
  • +
+

Version 1.4

+

New

+
    +
  • Optionally delete the vault if a panic trigger is received from Ripple
  • +
  • More customizable auto-lock
  • +
  • More flexible export options +
      +
    • Share mechanism
    • +
    • Offer to encrypt even if this feature is disabled in the app
    • +
    • Export to a Google Authenticator URI file
    • +
    +
  • +
  • Perform exports/backups on a background thread (automatic backups now work with Nextcloud)
  • +
  • Color improvements to the dark theme (slightly darker)
  • +
  • Offer more locations to select an image/icon from
  • +
  • Display some helpful information when importing from a different app
  • +
  • Minimum tap to reveal timeout changed to 1 second
  • +
  • After an entry is added, scroll to it and highlight it
  • +
  • Updated translations, and new translations for: Basque, Chinese Traditional, Hindi, Indonesian, Japanese, Persian, Romanian, Slovak
  • +
+

Fixes

+
    +
  • Scanning large images for QR codes would fail
  • +
  • The FAB would remain hidden under certain circumstances
  • +
  • The app would crash if an entry was added to the vault twice due to an IO error
  • +
  • The app would crash if the device was rotated while a progress dialog was shown
  • +
  • The PIN keyboard would show even if a new non-digit password was set
  • +
  • The password reminder popup would be occluded by the autofill popup
  • +
  • Importing from other apps on Android 11 was broken due to some permission issues
  • +
+

Version 1.3

+

New

+
    +
  • Completely rewritten intro/onboarding
  • +
  • Option to show a PIN keyboard when unlocking Aegis
  • +
  • A password strength meter when setting up encryption (based on zxcvbn)
  • +
  • RTL support
  • +
  • Arabic and Portuguese translations
  • +
  • Updates to existing translations
  • +
+

Fixes

+
    +
  • Better lifecycle handling of the biometric authentication prompt
  • +
  • The filename of exported vaults had a double .json extension
  • +
  • The navigation bar color was incorrect on devices pre API 27
  • +
  • QR code scanner performance and stability improvements
  • +
  • Various other small usability and stability improvements
  • +
+

Version 1.2.1

+

Fixes

+
    +
  • Fix a rare issue where the intro could end up in a bad state
  • +
+

Version 1.2

+

New

+
    +
  • Add navigation bar color to themes
  • +
  • Add support for importing from TOTP Authenticator
  • +
  • Add support for importing from Microsoft Authenticator
  • +
  • Add support for importing from Authenticator Plus
  • +
  • Add support for importing a plain text Google Authenticator URI file
  • +
  • Add support for importing from the new Google Authenticator export QR codes
  • +
  • Add support for otpauth://steam URI's
  • +
  • Add an option to copy tokens on tap (and disable it by default)
  • +
  • Improve method to notify users on copy
  • +
  • Add support for backups
  • +
  • Improve multiselect flow
  • +
  • Automatically adapt to system theme
  • +
  • Add setting to change from 3 digit group size to 2 digit group size
  • +
  • Use most frequent period to show progress
  • +
  • Append a timestamp to the filename of exported vaults
  • +
  • Add Hungarian translation
  • +
  • Add Turkish translation
  • +
  • Display a warning if automatic time sync is not enabled
  • +
  • Minor card entry layout overhaul
  • +
  • Ability to transfer tokens with qr codes
  • +
  • Lockscreen overhaul
  • +
+

Fixes

+
    +
  • Improve overall exception handling and error feedback to the user
  • +
  • Improve icon editing flow
  • +
  • Protect writes of the vault file against corruption with AtomicFile
  • +
  • Make the parsing logic of the QR code URI more robust
  • +
  • Importing from Authy now asks for password if needed
  • +
  • Update Russian localization
  • +
  • Increase password reminder period to 30 days
  • +
  • Fix importing andOTP backups with more than 10000 PBKDF iterations
  • +
  • Respect the global animator duration scale setting
  • +
+

Various other minor improvements

+

Version 1.1.4

+

Fixes

+
    +
  • The export filename was missing the ".json" extension in some cases
  • +
+

Version 1.1.3

+

New

+
    +
  • Password reminder for users who use biometric unlock
  • +
+

Fixes

+
    +
  • Tokens would not refresh in some rare cases
  • +
+

Version 1.1.2

+

New

+
    +
  • Ability to select multiple entries
  • +
  • Ability to select a file location when exporting the vault (including cloud providers like Google Drive)
  • +
  • Explanation and warning for the security options
  • +
  • Removed external storage permissions
  • +
+

Version 1.1.1

+

Fixes

+
    +
  • Exporting the vault did not work on Android 10
  • +
+

Version 1.1

+

New

+
    +
  • Support for other types of biometric authentication (i.e. Pixel 4 face unlock)
  • +
  • Support for importing from WinAuth
  • +
  • Support for Chromebooks
  • +
  • Option to highlight entries when tapped
  • +
  • Filter for ungrouped tokens
  • +
  • Ability to search for token account names
  • +
  • Simplified Chinese translation (thanks RunningMelos!)
  • +
  • Updated translations (thanks to all Crowdin contributers!)
  • +
+

Fixes

+
    +
  • The behavior of highlighting and revealing entries was inconsistent
  • +
  • The changelog dialog didn't work
  • +
  • The persistent notification was shown even after the app was killed
  • +
+

Version 1.0.3

+

New

+
    +
  • Support for andOTP's new backup file format
  • +
+

Version 1.0.2

+

Fixes

+
    +
  • Search feature on Huawei devices
  • +
+

Notes

+
    +
  • Disabled automatic backups through the Google Play Store
  • +
+

Version 1.0.1

+

Notes

+
    +
  • Temporarily disabled search feature on Huawei devices
  • +
+

Version 1.0

+

New

+
    +
  • New icon
  • +
  • Overhaul of interaction with the entry list
  • +
  • Persistent notification while the vault is unlocked
  • +
  • Language override option
  • +
  • Support for importing from FreeOTP+
  • +
  • Ability to toggle password visibility during unlock
  • +
  • Support for deeplinking otpauth URIs
  • +
+

Fixes

+
    +
  • Bad overall performance and high battery usage
  • +
  • Codes with an uneven number of digits are displayed incorrectly
  • +
  • Crash when entering a large value for OTP period
  • +
+ + \ No newline at end of file diff --git a/app/src/main/assets/license.html b/app/src/main/assets/license.html new file mode 100644 index 0000000..812429a --- /dev/null +++ b/app/src/main/assets/license.html @@ -0,0 +1,14 @@ + + + + + +
%1$s
+ + diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 0000000..af8400d Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/ic_launcher_debug-web.png b/app/src/main/ic_launcher_debug-web.png new file mode 100644 index 0000000..c5ca6ca Binary files /dev/null and b/app/src/main/ic_launcher_debug-web.png differ diff --git a/app/src/main/java/com/amulyakhare/textdrawable/LICENSE b/app/src/main/java/com/amulyakhare/textdrawable/LICENSE new file mode 100644 index 0000000..f864661 --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014 Amulya Khare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java b/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java new file mode 100644 index 0000000..f410571 --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java @@ -0,0 +1,316 @@ +package com.amulyakhare.textdrawable; + +import android.graphics.*; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.graphics.drawable.shapes.RectShape; +import android.graphics.drawable.shapes.RoundRectShape; + +/** + * @author amulya + * @datetime 14 Oct 2014, 3:53 PM + */ +public class TextDrawable extends ShapeDrawable { + + private final Paint textPaint; + private final Paint borderPaint; + private static final float SHADE_FACTOR = 0.9f; + private final String text; + private final int color; + private final RectShape shape; + private final int height; + private final int width; + private final int fontSize; + private final float radius; + private final int borderThickness; + + private TextDrawable(Builder builder) { + super(builder.shape); + + // shape properties + shape = builder.shape; + height = builder.height; + width = builder.width; + radius = builder.radius; + + // text and color + text = builder.toUpperCase ? builder.text.toUpperCase() : builder.text; + color = builder.color; + + // text paint settings + fontSize = builder.fontSize; + textPaint = new Paint(); + textPaint.setColor(builder.textColor); + textPaint.setAntiAlias(true); + textPaint.setFakeBoldText(builder.isBold); + textPaint.setStyle(Paint.Style.FILL); + textPaint.setTypeface(builder.font); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setStrokeWidth(builder.borderThickness); + + // border paint settings + borderThickness = builder.borderThickness; + borderPaint = new Paint(); + borderPaint.setColor(getDarkerShade(color)); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(borderThickness); + + // drawable paint color + Paint paint = getPaint(); + paint.setColor(color); + + } + + private int getDarkerShade(int color) { + return Color.rgb((int)(SHADE_FACTOR * Color.red(color)), + (int)(SHADE_FACTOR * Color.green(color)), + (int)(SHADE_FACTOR * Color.blue(color))); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + Rect r = getBounds(); + + + // draw border + if (borderThickness > 0) { + drawBorder(canvas); + } + + int count = canvas.save(); + canvas.translate(r.left, r.top); + + // draw text + int width = this.width < 0 ? r.width() : this.width; + int height = this.height < 0 ? r.height() : this.height; + int fontSize = this.fontSize < 0 ? (Math.min(width, height) / 2) : this.fontSize; + textPaint.setTextSize(fontSize); + canvas.drawText(text, width / 2, height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2), textPaint); + + canvas.restoreToCount(count); + + } + + private void drawBorder(Canvas canvas) { + RectF rect = new RectF(getBounds()); + rect.inset(borderThickness/2, borderThickness/2); + + if (shape instanceof OvalShape) { + canvas.drawOval(rect, borderPaint); + } + else if (shape instanceof RoundRectShape) { + canvas.drawRoundRect(rect, radius, radius, borderPaint); + } + else { + canvas.drawRect(rect, borderPaint); + } + } + + @Override + public void setAlpha(int alpha) { + textPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter cf) { + textPaint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public int getIntrinsicWidth() { + return width; + } + + @Override + public int getIntrinsicHeight() { + return height; + } + + public static IShapeBuilder builder() { + return new Builder(); + } + + public static class Builder implements IConfigBuilder, IShapeBuilder, IBuilder { + + private String text; + + private int color; + + private int borderThickness; + + private int width; + + private int height; + + private Typeface font; + + private RectShape shape; + + public int textColor; + + private int fontSize; + + private boolean isBold; + + private boolean toUpperCase; + + public float radius; + + private Builder() { + text = ""; + color = Color.GRAY; + textColor = Color.WHITE; + borderThickness = 0; + width = -1; + height = -1; + shape = new RectShape(); + font = Typeface.create("sans-serif-light", Typeface.NORMAL); + fontSize = -1; + isBold = false; + toUpperCase = false; + } + + public IConfigBuilder width(int width) { + this.width = width; + return this; + } + + public IConfigBuilder height(int height) { + this.height = height; + return this; + } + + public IConfigBuilder textColor(int color) { + this.textColor = color; + return this; + } + + public IConfigBuilder withBorder(int thickness) { + this.borderThickness = thickness; + return this; + } + + public IConfigBuilder useFont(Typeface font) { + this.font = font; + return this; + } + + public IConfigBuilder fontSize(int size) { + this.fontSize = size; + return this; + } + + public IConfigBuilder bold() { + this.isBold = true; + return this; + } + + public IConfigBuilder toUpperCase() { + this.toUpperCase = true; + return this; + } + + @Override + public IConfigBuilder beginConfig() { + return this; + } + + @Override + public IShapeBuilder endConfig() { + return this; + } + + @Override + public IBuilder rect() { + this.shape = new RectShape(); + return this; + } + + @Override + public IBuilder round() { + this.shape = new OvalShape(); + return this; + } + + @Override + public IBuilder roundRect(int radius) { + this.radius = radius; + float[] radii = {radius, radius, radius, radius, radius, radius, radius, radius}; + this.shape = new RoundRectShape(radii, null, null); + return this; + } + + @Override + public TextDrawable buildRect(String text, int color) { + rect(); + return build(text, color); + } + + @Override + public TextDrawable buildRoundRect(String text, int color, int radius) { + roundRect(radius); + return build(text, color); + } + + @Override + public TextDrawable buildRound(String text, int color) { + round(); + return build(text, color); + } + + @Override + public TextDrawable build(String text, int color) { + this.color = color; + this.text = text; + return new TextDrawable(this); + } + } + + public interface IConfigBuilder { + public IConfigBuilder width(int width); + + public IConfigBuilder height(int height); + + public IConfigBuilder textColor(int color); + + public IConfigBuilder withBorder(int thickness); + + public IConfigBuilder useFont(Typeface font); + + public IConfigBuilder fontSize(int size); + + public IConfigBuilder bold(); + + public IConfigBuilder toUpperCase(); + + public IShapeBuilder endConfig(); + } + + public static interface IBuilder { + + public TextDrawable build(String text, int color); + } + + public static interface IShapeBuilder { + + public IConfigBuilder beginConfig(); + + public IBuilder rect(); + + public IBuilder round(); + + public IBuilder roundRect(int radius); + + public TextDrawable buildRect(String text, int color); + + public TextDrawable buildRoundRect(String text, int color, int radius); + + public TextDrawable buildRound(String text, int color); + } +} diff --git a/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java b/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java new file mode 100644 index 0000000..7efe7d5 --- /dev/null +++ b/app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java @@ -0,0 +1,69 @@ +package com.amulyakhare.textdrawable.util; + +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +/** + * @author amulya + * @datetime 14 Oct 2014, 5:20 PM + */ +public class ColorGenerator { + + public static ColorGenerator DEFAULT; + + public static ColorGenerator MATERIAL; + + static { + DEFAULT = create(Arrays.asList( + 0xfff16364, + 0xfff58559, + 0xfff9a43e, + 0xffe4c62e, + 0xff67bf74, + 0xff59a2be, + 0xff2093cd, + 0xffad62a7, + 0xff805781 + )); + MATERIAL = create(Arrays.asList( + 0xffe57373, + 0xfff06292, + 0xffba68c8, + 0xff9575cd, + 0xff7986cb, + 0xff64b5f6, + 0xff4fc3f7, + 0xff4dd0e1, + 0xff4db6ac, + 0xff81c784, + 0xffaed581, + 0xffff8a65, + 0xffd4e157, + 0xffffd54f, + 0xffffb74d, + 0xffa1887f, + 0xff90a4ae + )); + } + + private final List mColors; + private final Random mRandom; + + public static ColorGenerator create(List colorList) { + return new ColorGenerator(colorList); + } + + private ColorGenerator(List colorList) { + mColors = colorList; + mRandom = new Random(System.currentTimeMillis()); + } + + public int getRandomColor() { + return mColors.get(mRandom.nextInt(mColors.size())); + } + + public int getColor(Object key) { + return mColors.get(Math.abs(key.hashCode()) % mColors.size()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java b/app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java new file mode 100644 index 0000000..bbc4a07 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis; + +public enum AccountNamePosition { + HIDDEN, + END, + BELOW; + + private static AccountNamePosition[] _values; + + static { + _values = values(); + } + + public static AccountNamePosition fromInteger(int x) { + return _values[x]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java b/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java new file mode 100644 index 0000000..b1c3e22 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java @@ -0,0 +1,8 @@ +package com.beemdevelopment.aegis; + +import dagger.hilt.android.HiltAndroidApp; + +@HiltAndroidApp +public class AegisApplication extends AegisApplicationBase { + +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java b/app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java new file mode 100644 index 0000000..2d973fa --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java @@ -0,0 +1,121 @@ +package com.beemdevelopment.aegis; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.drawable.Icon; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.ProcessLifecycleOwner; + +import com.beemdevelopment.aegis.receivers.VaultLockReceiver; +import com.beemdevelopment.aegis.ui.MainActivity; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultManager; +import com.topjohnwu.superuser.Shell; + +import java.util.Collections; + +import dagger.hilt.InstallIn; +import dagger.hilt.android.EarlyEntryPoint; +import dagger.hilt.android.EarlyEntryPoints; +import dagger.hilt.components.SingletonComponent; + +public abstract class AegisApplicationBase extends Application { + private static final String CODE_LOCK_STATUS_ID = "lock_status_channel"; + + private VaultManager _vaultManager; + + static { + // Enable verbose libsu logging in debug builds + Shell.enableVerboseLogging = BuildConfig.DEBUG; + } + + @Override + public void onCreate() { + super.onCreate(); + _vaultManager = EarlyEntryPoints.get(this, EntryPoint.class).getVaultManager(); + + VaultLockReceiver lockReceiver = new VaultLockReceiver(); + IntentFilter intentFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF); + ContextCompat.registerReceiver(this, lockReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED); + + // lock the app if the user moves the application to the background + ProcessLifecycleOwner.get().getLifecycle().addObserver(new AppLifecycleObserver()); + + // clear the cache directory on startup, to make sure no temporary vault export files remain + IOUtils.clearDirectory(getCacheDir(), false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + initAppShortcuts(); + } + + // NOTE: Disabled for now. See issue: #1047 + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + initNotificationChannels(); + }*/ + } + + @RequiresApi(api = Build.VERSION_CODES.N_MR1) + private void initAppShortcuts() { + ShortcutManager shortcutManager = getSystemService(ShortcutManager.class); + if (shortcutManager == null) { + return; + } + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("action", "scan"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction(Intent.ACTION_MAIN); + + ShortcutInfo shortcut = new ShortcutInfo.Builder(this, "shortcut_new") + .setShortLabel(getString(R.string.new_entry)) + .setLongLabel(getString(R.string.add_new_entry)) + .setIcon(Icon.createWithResource(this, R.drawable.ic_qr_code)) + .setIntent(intent) + .build(); + + shortcutManager.setDynamicShortcuts(Collections.singletonList(shortcut)); + } + + private void initNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.channel_name_lock_status); + String description = getString(R.string.channel_description_lock_status); + int importance = NotificationManager.IMPORTANCE_LOW; + + NotificationChannel channel = new NotificationChannel(CODE_LOCK_STATUS_ID, name, importance); + channel.setDescription(description); + + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + private class AppLifecycleObserver implements LifecycleEventObserver { + @Override + public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + if (event == Lifecycle.Event.ON_STOP + && _vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_MINIMIZE) + && !_vaultManager.isAutoLockBlocked()) { + _vaultManager.lock(false); + } + } + } + + @EarlyEntryPoint + @InstallIn(SingletonComponent.class) + interface EntryPoint { + VaultManager getVaultManager(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java b/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java new file mode 100644 index 0000000..3a08e6e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisModule.java b/app/src/main/java/com/beemdevelopment/aegis/AegisModule.java new file mode 100644 index 0000000..bdaae0d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisModule.java @@ -0,0 +1,55 @@ +package com.beemdevelopment.aegis; + +import android.content.Context; + +import androidx.room.Room; + +import com.beemdevelopment.aegis.database.AppDatabase; +import com.beemdevelopment.aegis.database.AuditLogDao; +import com.beemdevelopment.aegis.database.AuditLogRepository; +import com.beemdevelopment.aegis.icons.IconPackManager; +import com.beemdevelopment.aegis.vault.VaultManager; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.qualifiers.ApplicationContext; +import dagger.hilt.components.SingletonComponent; + +@Module +@InstallIn(SingletonComponent.class) +public class AegisModule { + @Provides + @Singleton + public static IconPackManager provideIconPackManager(@ApplicationContext Context context) { + return new IconPackManager(context); + } + + @Provides + @Singleton + public static AuditLogRepository provideAuditLogRepository(AppDatabase appDatabase) { + AuditLogDao auditLogDao = appDatabase.auditLogDao(); + return new AuditLogRepository(auditLogDao); + } + + @Provides + @Singleton + public static VaultManager provideVaultManager(@ApplicationContext Context context, AuditLogRepository auditLogRepository) { + return new VaultManager(context, auditLogRepository); + } + + @Provides + public static Preferences providePreferences(@ApplicationContext Context context) { + return new Preferences(context); + } + + @Provides + @Singleton + public static AppDatabase provideAppDatabase(@ApplicationContext Context context) { + return Room.databaseBuilder(context.getApplicationContext(), + AppDatabase.class, "aegis-db") + .build(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java b/app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java new file mode 100644 index 0000000..0e06954 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis; + +public enum BackupsVersioningStrategy { + UNDEFINED, + MULTIPLE_BACKUPS, + SINGLE_BACKUP +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java b/app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java new file mode 100644 index 0000000..4b9b844 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis; + +public enum CopyBehavior { + NEVER, + SINGLETAP, + DOUBLETAP; + + private static CopyBehavior[] _values; + + static { + _values = values(); + } + + public static CopyBehavior fromInteger(int x) { + return _values[x]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/EventType.java b/app/src/main/java/com/beemdevelopment/aegis/EventType.java new file mode 100644 index 0000000..4588ee0 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/EventType.java @@ -0,0 +1,42 @@ +package com.beemdevelopment.aegis; + +public enum EventType { + + VAULT_UNLOCKED, + VAULT_BACKUP_CREATED, + VAULT_ANDROID_BACKUP_CREATED, + VAULT_EXPORTED, + ENTRY_SHARED, + VAULT_UNLOCK_FAILED_PASSWORD, + VAULT_UNLOCK_FAILED_BIOMETRICS; + private static EventType[] _values; + + static { + _values = values(); + } + + public static EventType fromInteger(int x) { + return _values[x]; + } + + public static int getEventTitleRes(EventType eventType) { + switch (eventType) { + case VAULT_UNLOCKED: + return R.string.event_title_vault_unlocked; + case VAULT_BACKUP_CREATED: + return R.string.event_title_backup_created; + case VAULT_ANDROID_BACKUP_CREATED: + return R.string.event_title_android_backup_created; + case VAULT_EXPORTED: + return R.string.event_title_vault_exported; + case ENTRY_SHARED: + return R.string.event_title_entry_shared; + case VAULT_UNLOCK_FAILED_PASSWORD: + return R.string.event_title_vault_unlock_failed_password; + case VAULT_UNLOCK_FAILED_BIOMETRICS: + return R.string.event_title_vault_unlock_failed_biometrics; + default: + return R.string.event_unknown; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java b/app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java new file mode 100644 index 0000000..6169e80 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java @@ -0,0 +1,20 @@ +package com.beemdevelopment.aegis; + +public enum GroupPlaceholderType { + ALL, + NEW_GROUP, + NO_GROUP; + + public int getStringRes() { + switch (this) { + case ALL: + return R.string.all; + case NEW_GROUP: + return R.string.new_group; + case NO_GROUP: + return R.string.no_group; + default: + throw new IllegalArgumentException("Unexpected placeholder type: " + this); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java b/app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java new file mode 100644 index 0000000..56c9df3 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java @@ -0,0 +1,56 @@ +package com.beemdevelopment.aegis; + +import androidx.annotation.StringRes; + +import java.util.concurrent.TimeUnit; + +public enum PassReminderFreq { + NEVER, + WEEKLY, + BIWEEKLY, + MONTHLY, + QUARTERLY; + + public long getDurationMillis() { + long weeks; + switch (this) { + case WEEKLY: + weeks = 1; + break; + case BIWEEKLY: + weeks = 2; + break; + case MONTHLY: + weeks = 4; + break; + case QUARTERLY: + weeks = 13; + break; + default: + weeks = 0; + break; + } + + return TimeUnit.MILLISECONDS.convert(weeks * 7L, TimeUnit.DAYS); + } + + @StringRes + public int getStringRes() { + switch (this) { + case WEEKLY: + return R.string.password_reminder_freq_weekly; + case BIWEEKLY: + return R.string.password_reminder_freq_biweekly; + case MONTHLY: + return R.string.password_reminder_freq_monthly; + case QUARTERLY: + return R.string.password_reminder_freq_quarterly; + default: + return R.string.password_reminder_freq_never; + } + } + + public static PassReminderFreq fromInteger(int i) { + return PassReminderFreq.values()[i]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java new file mode 100644 index 0000000..7ccda05 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java @@ -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 usageCounts = getUsageCounts(); + usageCounts.put(uuid, 0); + + setUsageCount(usageCounts); + } + + public long getLastUsedTimestamp(UUID uuid) { + Map timestamps = getLastUsedTimestamps(); + if (timestamps != null && timestamps.size() > 0){ + Long timestamp = timestamps.get(uuid); + return timestamp != null ? timestamp : 0; + } + + return 0; + } + + public void clearUsageCount() { + _prefs.edit().remove("pref_usage_count").apply(); + } + + public Map getLastUsedTimestamps() { + Map lastUsedTimestamps = new HashMap<>(); + String lastUsedTimestamp = _prefs.getString("pref_last_used_timestamps", ""); + try { + JSONArray arr = new JSONArray(lastUsedTimestamp); + for (int i = 0; i < arr.length(); i++) { + JSONObject json = arr.getJSONObject(i); + lastUsedTimestamps.put(UUID.fromString(json.getString("uuid")), json.getLong("timestamp")); + } + } catch (JSONException ignored) { + } + + return lastUsedTimestamps; + } + + public void setLastUsedTimestamps(Map lastUsedTimestamps) { + JSONArray lastUsedTimestampJson = new JSONArray(); + for (Map.Entry entry : lastUsedTimestamps.entrySet()) { + JSONObject entryJson = new JSONObject(); + try { + entryJson.put("uuid", entry.getKey()); + entryJson.put("timestamp", entry.getValue()); + lastUsedTimestampJson.put(entryJson); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + _prefs.edit().putString("pref_last_used_timestamps", lastUsedTimestampJson.toString()).apply(); + } + + public Map getUsageCounts() { + Map usageCounts = new HashMap<>(); + String usageCount = _prefs.getString("pref_usage_count", ""); + try { + JSONArray arr = new JSONArray(usageCount); + for (int i = 0; i < arr.length(); i++) { + JSONObject json = arr.getJSONObject(i); + usageCounts.put(UUID.fromString(json.getString("uuid")), json.getInt("count")); + } + } catch (JSONException ignored) { + } + + return usageCounts; + } + + public void setUsageCount(Map usageCounts) { + JSONArray usageCountJson = new JSONArray(); + for (Map.Entry 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 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 groupFilter) { + JSONArray json = new JSONArray(groupFilter); + _prefs.edit().putString("pref_group_filter_uuids", json.toString()).apply(); + } + + public Set 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 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; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java b/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java new file mode 100644 index 0000000..a7b3f66 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/SortCategory.java @@ -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 getComparator() { + Comparator 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; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/Theme.java b/app/src/main/java/com/beemdevelopment/aegis/Theme.java new file mode 100644 index 0000000..b5e5975 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/Theme.java @@ -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]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java b/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java new file mode 100644 index 0000000..26859bc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java @@ -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 DEFAULT = ImmutableMap.of( + Theme.LIGHT, R.style.Theme_Aegis_Light, + Theme.DARK, R.style.Theme_Aegis_Dark, + Theme.AMOLED, R.style.Theme_Aegis_Amoled + ); +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java b/app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java new file mode 100644 index 0000000..6e8de25 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis; + +import java.util.Arrays; + +public class VibrationPatterns { + public static final long[] EXPIRING = {475, 20, 5, 20, 965, 20, 5, 20, 965, 20, 5, 20, 420}; + public static final long[] REFRESH_CODE = {0, 100}; + + public static long getLengthInMillis(long[] pattern) { + return Arrays.stream(pattern).sum(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java b/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java new file mode 100644 index 0000000..a962aa5 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ViewMode.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptParameters.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptParameters.java new file mode 100644 index 0000000..3f1a55b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptParameters.java @@ -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; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptResult.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptResult.java new file mode 100644 index 0000000..b68c142 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptResult.java @@ -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; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java new file mode 100644 index 0000000..00b2bd6 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java @@ -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(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java new file mode 100644 index 0000000..6ed1c05 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandleException.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandleException.java new file mode 100644 index 0000000..ff54c31 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandleException.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKey.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKey.java new file mode 100644 index 0000000..bf72c48 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKey.java @@ -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(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKeyException.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKeyException.java new file mode 100644 index 0000000..a0a2198 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKeyException.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis.crypto; + +public class MasterKeyException extends Exception { + public MasterKeyException(Throwable cause) { + super(cause); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/SCryptParameters.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/SCryptParameters.java new file mode 100644 index 0000000..1ba4c4a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/SCryptParameters.java @@ -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; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java new file mode 100644 index 0000000..068362a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java @@ -0,0 +1,255 @@ +/* +Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + */ + +package com.beemdevelopment.aegis.crypto.bc; + +import org.bouncycastle.crypto.PBEParametersGenerator; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Integers; +import org.bouncycastle.util.Pack; + +/** + * Implementation of the scrypt a password-based key derivation function. + *

+ * Scrypt was created by Colin Percival and is specified in RFC 7914 - The scrypt Password-Based Key Derivation Function + */ +public class SCrypt +{ + private SCrypt() + { + // not used. + } + + /** + * Generate a key using the scrypt key derivation function. + * + * @param P the bytes of the pass phrase. + * @param S the salt to use for this invocation. + * @param N CPU/Memory cost parameter. Must be larger than 1, a power of 2 and less than + * 2^(128 * r / 8). + * @param r the block size, must be >= 1. + * @param p Parallelization parameter. Must be a positive integer less than or equal to + * Integer.MAX_VALUE / (128 * r * 8). + * @param dkLen the length of the key to generate. + * @return the generated key. + */ + public static byte[] generate(byte[] P, byte[] S, int N, int r, int p, int dkLen) + { + if (P == null) + { + throw new IllegalArgumentException("Passphrase P must be provided."); + } + if (S == null) + { + throw new IllegalArgumentException("Salt S must be provided."); + } + if (N <= 1 || !isPowerOf2(N)) + { + throw new IllegalArgumentException("Cost parameter N must be > 1 and a power of 2"); + } + // Only value of r that cost (as an int) could be exceeded for is 1 + if (r == 1 && N >= 65536) + { + throw new IllegalArgumentException("Cost parameter N must be > 1 and < 65536."); + } + if (r < 1) + { + throw new IllegalArgumentException("Block size r must be >= 1."); + } + int maxParallel = Integer.MAX_VALUE / (128 * r * 8); + if (p < 1 || p > maxParallel) + { + throw new IllegalArgumentException("Parallelisation parameter p must be >= 1 and <= " + maxParallel + + " (based on block size r of " + r + ")"); + } + if (dkLen < 1) + { + throw new IllegalArgumentException("Generated key length dkLen must be >= 1."); + } + return MFcrypt(P, S, N, r, p, dkLen); + } + + private static byte[] MFcrypt(byte[] P, byte[] S, int N, int r, int p, int dkLen) + { + int MFLenBytes = r * 128; + byte[] bytes = SingleIterationPBKDF2(P, S, p * MFLenBytes); + + int[] B = null; + + try + { + int BLen = bytes.length >>> 2; + B = new int[BLen]; + + Pack.littleEndianToInt(bytes, 0, B); + + /* + * Chunk memory allocations; We choose 'd' so that there will be 2**d chunks, each not + * larger than 32KiB, except that the minimum chunk size is 2 * r * 32. + */ + int d = 0, total = N * r; + while ((N - d) > 2 && total > (1 << 10)) + { + ++d; + total >>>= 1; + } + + int MFLenWords = MFLenBytes >>> 2; + for (int BOff = 0; BOff < BLen; BOff += MFLenWords) + { + // TODO These can be done in parallel threads + SMix(B, BOff, N, d, r); + } + + Pack.intToLittleEndian(B, bytes, 0); + + return SingleIterationPBKDF2(P, bytes, dkLen); + } + finally + { + Clear(bytes); + Clear(B); + } + } + + private static byte[] SingleIterationPBKDF2(byte[] P, byte[] S, int dkLen) + { + PBEParametersGenerator pGen = new PKCS5S2ParametersGenerator(new SHA256Digest()); + pGen.init(P, S, 1); + KeyParameter key = (KeyParameter)pGen.generateDerivedMacParameters(dkLen * 8); + return key.getKey(); + } + + private static void SMix(int[] B, int BOff, int N, int d, int r) + { + int powN = Integers.numberOfTrailingZeros(N); + int blocksPerChunk = N >>> d; + int chunkCount = 1 << d, chunkMask = blocksPerChunk - 1, chunkPow = powN - d; + + int BCount = r * 32; + + int[] blockX1 = new int[16]; + int[] blockX2 = new int[16]; + int[] blockY = new int[BCount]; + + int[] X = new int[BCount]; + int[][] VV = new int[chunkCount][]; + + try + { + System.arraycopy(B, BOff, X, 0, BCount); + + for (int c = 0; c < chunkCount; ++c) + { + int[] V = new int[blocksPerChunk * BCount]; + VV[c] = V; + + int off = 0; + for (int i = 0; i < blocksPerChunk; i += 2) + { + System.arraycopy(X, 0, V, off, BCount); + off += BCount; + BlockMix(X, blockX1, blockX2, blockY, r); + System.arraycopy(blockY, 0, V, off, BCount); + off += BCount; + BlockMix(blockY, blockX1, blockX2, X, r); + } + } + + int mask = N - 1; + for (int i = 0; i < N; ++i) + { + int j = X[BCount - 16] & mask; + int[] V = VV[j >>> chunkPow]; + int VOff = (j & chunkMask) * BCount; + System.arraycopy(V, VOff, blockY, 0, BCount); + Xor(blockY, X, 0, blockY); + BlockMix(blockY, blockX1, blockX2, X, r); + } + + System.arraycopy(X, 0, B, BOff, BCount); + } + finally + { + ClearAll(VV); + ClearAll(new int[][]{X, blockX1, blockX2, blockY}); + } + } + + private static void BlockMix(int[] B, int[] X1, int[] X2, int[] Y, int r) + { + System.arraycopy(B, B.length - 16, X1, 0, 16); + + int BOff = 0, YOff = 0, halfLen = B.length >>> 1; + + for (int i = 2 * r; i > 0; --i) + { + Xor(X1, B, BOff, X2); + + Salsa20Engine.salsaCore(8, X2, X1); + System.arraycopy(X1, 0, Y, YOff, 16); + + YOff = halfLen + BOff - YOff; + BOff += 16; + } + } + + private static void Xor(int[] a, int[] b, int bOff, int[] output) + { + for (int i = output.length - 1; i >= 0; --i) + { + output[i] = a[i] ^ b[bOff + i]; + } + } + + private static void Clear(byte[] array) + { + if (array != null) + { + Arrays.fill(array, (byte)0); + } + } + + private static void Clear(int[] array) + { + if (array != null) + { + Arrays.fill(array, 0); + } + } + + private static void ClearAll(int[][] arrays) + { + for (int i = 0; i < arrays.length; ++i) + { + Clear(arrays[i]); + } + } + + // note: we know X is non-zero + private static boolean isPowerOf2(int x) + { + return ((x & (x - 1)) == 0); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java new file mode 100644 index 0000000..8709cab --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java @@ -0,0 +1,118 @@ +/* +Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + */ + +package com.beemdevelopment.aegis.crypto.bc; + +/** + * Implementation of Daniel J. Bernstein's Salsa20 stream cipher, Snuffle 2005 + */ +public class Salsa20Engine { + private Salsa20Engine() + { + + } + + public static void salsaCore(int rounds, int[] input, int[] x) + { + if (input.length != 16) + { + throw new IllegalArgumentException(); + } + if (x.length != 16) + { + throw new IllegalArgumentException(); + } + if (rounds % 2 != 0) + { + throw new IllegalArgumentException("Number of rounds must be even"); + } + + int x00 = input[ 0]; + int x01 = input[ 1]; + int x02 = input[ 2]; + int x03 = input[ 3]; + int x04 = input[ 4]; + int x05 = input[ 5]; + int x06 = input[ 6]; + int x07 = input[ 7]; + int x08 = input[ 8]; + int x09 = input[ 9]; + int x10 = input[10]; + int x11 = input[11]; + int x12 = input[12]; + int x13 = input[13]; + int x14 = input[14]; + int x15 = input[15]; + + for (int i = rounds; i > 0; i -= 2) + { + x04 ^= Integer.rotateLeft(x00 + x12, 7); + x08 ^= Integer.rotateLeft(x04 + x00, 9); + x12 ^= Integer.rotateLeft(x08 + x04, 13); + x00 ^= Integer.rotateLeft(x12 + x08, 18); + x09 ^= Integer.rotateLeft(x05 + x01, 7); + x13 ^= Integer.rotateLeft(x09 + x05, 9); + x01 ^= Integer.rotateLeft(x13 + x09, 13); + x05 ^= Integer.rotateLeft(x01 + x13, 18); + x14 ^= Integer.rotateLeft(x10 + x06, 7); + x02 ^= Integer.rotateLeft(x14 + x10, 9); + x06 ^= Integer.rotateLeft(x02 + x14, 13); + x10 ^= Integer.rotateLeft(x06 + x02, 18); + x03 ^= Integer.rotateLeft(x15 + x11, 7); + x07 ^= Integer.rotateLeft(x03 + x15, 9); + x11 ^= Integer.rotateLeft(x07 + x03, 13); + x15 ^= Integer.rotateLeft(x11 + x07, 18); + + x01 ^= Integer.rotateLeft(x00 + x03, 7); + x02 ^= Integer.rotateLeft(x01 + x00, 9); + x03 ^= Integer.rotateLeft(x02 + x01, 13); + x00 ^= Integer.rotateLeft(x03 + x02, 18); + x06 ^= Integer.rotateLeft(x05 + x04, 7); + x07 ^= Integer.rotateLeft(x06 + x05, 9); + x04 ^= Integer.rotateLeft(x07 + x06, 13); + x05 ^= Integer.rotateLeft(x04 + x07, 18); + x11 ^= Integer.rotateLeft(x10 + x09, 7); + x08 ^= Integer.rotateLeft(x11 + x10, 9); + x09 ^= Integer.rotateLeft(x08 + x11, 13); + x10 ^= Integer.rotateLeft(x09 + x08, 18); + x12 ^= Integer.rotateLeft(x15 + x14, 7); + x13 ^= Integer.rotateLeft(x12 + x15, 9); + x14 ^= Integer.rotateLeft(x13 + x12, 13); + x15 ^= Integer.rotateLeft(x14 + x13, 18); + } + + x[ 0] = x00 + input[ 0]; + x[ 1] = x01 + input[ 1]; + x[ 2] = x02 + input[ 2]; + x[ 3] = x03 + input[ 3]; + x[ 4] = x04 + input[ 4]; + x[ 5] = x05 + input[ 5]; + x[ 6] = x06 + input[ 6]; + x[ 7] = x07 + input[ 7]; + x[ 8] = x08 + input[ 8]; + x[ 9] = x09 + input[ 9]; + x[10] = x10 + input[10]; + x[11] = x11 + input[11]; + x[12] = x12 + input[12]; + x[13] = x13 + input[13]; + x[14] = x14 + input[14]; + x[15] = x15 + input[15]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java new file mode 100644 index 0000000..22dea18 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java new file mode 100644 index 0000000..2778e28 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java @@ -0,0 +1,54 @@ +package com.beemdevelopment.aegis.crypto.otp; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import com.beemdevelopment.aegis.encoding.Hex; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class MOTP { + private final String _code; + private final int _digits; + + private MOTP(String code, int digits) { + _code = code; + _digits = digits; + } + + @NonNull + public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin) + throws NoSuchAlgorithmException { + + return generateOTP(secret, algo, digits, period, pin, System.currentTimeMillis() / 1000); + } + + @NonNull + public static MOTP generateOTP(byte[] secret, String algo, int digits, int period, String pin, long time) + throws NoSuchAlgorithmException { + + long timeBasedCounter = time / period; + String secretAsString = Hex.encode(secret); + String toDigest = timeBasedCounter + secretAsString + pin; + String code = getDigest(algo, toDigest.getBytes(StandardCharsets.UTF_8)); + + return new MOTP(code, digits); + } + + @VisibleForTesting + @NonNull + protected static String getDigest(String algo, byte[] toDigest) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance(algo); + byte[] digest = md.digest(toDigest); + + return Hex.encode(digest); + } + + @NonNull + @Override + public String toString() { + return _code.substring(0, _digits); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java new file mode 100644 index 0000000..9480f97 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java @@ -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(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/TOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/TOTP.java new file mode 100644 index 0000000..19ae52a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/TOTP.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java new file mode 100644 index 0000000..676856c --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java @@ -0,0 +1,71 @@ +package com.beemdevelopment.aegis.crypto.otp; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +public class YAOTP { + private static final int EN_ALPHABET_LENGTH = 26; + private final long _code; + private final int _digits; + + private YAOTP(long code, int digits) { + _code = code; + _digits = digits; + } + + public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period) + throws NoSuchAlgorithmException, InvalidKeyException, IOException { + long seconds = System.currentTimeMillis() / 1000; + return generateOTP(secret, pin, digits, otpAlgo, period, seconds); + } + + public static YAOTP generateOTP(byte[] secret, String pin, int digits, String otpAlgo, long period, long seconds) + throws NoSuchAlgorithmException, InvalidKeyException, IOException { + byte[] pinWithHash; + byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8); + try (ByteArrayOutputStream stream = new ByteArrayOutputStream(pinBytes.length + secret.length)) { + stream.write(pinBytes); + stream.write(secret); + pinWithHash = stream.toByteArray(); + } + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] keyHash = md.digest(pinWithHash); + if (keyHash[0] == 0) { + keyHash = Arrays.copyOfRange(keyHash, 1, keyHash.length); + } + + long counter = (long) Math.floor((double) seconds / period); + byte[] periodHash = HOTP.getHash(keyHash, otpAlgo, counter); + int offset = periodHash[periodHash.length - 1] & 0xf; + periodHash[offset] &= 0x7f; + long otp = ByteBuffer.wrap(periodHash) + .order(ByteOrder.BIG_ENDIAN) + .getLong(offset); + + return new YAOTP(otp, digits); + } + + @NonNull + @Override + public String toString() { + long code = _code % (long) Math.pow(EN_ALPHABET_LENGTH, _digits); + char[] chars = new char[_digits]; + + for (int i = _digits - 1; i >= 0; i--) { + chars[i] = (char) ('a' + (code % EN_ALPHABET_LENGTH)); + code /= EN_ALPHABET_LENGTH; + } + + return new String(chars); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA2048.java b/app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA2048.java new file mode 100644 index 0000000..0f82b56 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA2048.java @@ -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},}; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java b/app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java new file mode 100644 index 0000000..073bb2e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java @@ -0,0 +1,15 @@ +package com.beemdevelopment.aegis.database; + +import android.content.Context; + +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Database(entities = {AuditLogEntry.class}, version = 1) +public abstract class AppDatabase extends RoomDatabase { + public abstract AuditLogDao auditLogDao(); +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java new file mode 100644 index 0000000..20c4a47 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis.database; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.Query; + +import java.util.List; + +@Dao +public interface AuditLogDao { + @Insert + void insert(AuditLogEntry log); + + @Query("SELECT * FROM audit_logs WHERE timestamp >= strftime('%s', 'now', '-30 days') ORDER BY timestamp DESC") + LiveData> getAll(); +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java new file mode 100644 index 0000000..b0888ee --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java @@ -0,0 +1,61 @@ +package com.beemdevelopment.aegis.database; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.PrimaryKey; + +import com.beemdevelopment.aegis.EventType; + +@Entity(tableName = "audit_logs") +public class AuditLogEntry { + @PrimaryKey(autoGenerate = true) + protected long id; + + @NonNull + @ColumnInfo(name = "event_type") + private final EventType _eventType; + + @ColumnInfo(name = "reference") + private final String _reference; + + @ColumnInfo(name = "timestamp") + private final long _timestamp; + + @Ignore + public AuditLogEntry(@NonNull EventType eventType) { + this(eventType, null); + } + + @Ignore + public AuditLogEntry(@NonNull EventType eventType, @Nullable String reference) { + _eventType = eventType; + _reference = reference; + _timestamp = System.currentTimeMillis(); + } + + AuditLogEntry(long id, @NonNull EventType eventType, @Nullable String reference, long timestamp) { + this.id = id; + _eventType = eventType; + _reference = reference; + _timestamp = timestamp; + } + + public long getId() { + return id; + } + + public EventType getEventType() { + return _eventType; + } + + public String getReference() { + return _reference; + } + + public long getTimestamp() { + return _timestamp; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java new file mode 100644 index 0000000..cde042f --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java @@ -0,0 +1,66 @@ +package com.beemdevelopment.aegis.database; + +import androidx.lifecycle.LiveData; + +import com.beemdevelopment.aegis.EventType; + +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class AuditLogRepository { + private final AuditLogDao _auditLogDao; + private final Executor _executor; + + public AuditLogRepository(AuditLogDao auditLogDao) { + _auditLogDao = auditLogDao; + _executor = Executors.newSingleThreadExecutor(); + } + + public LiveData> getAllAuditLogEntries() { + return _auditLogDao.getAll(); + } + + public void addVaultUnlockedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCKED); + insert(auditLogEntry); + } + + public void addBackupCreatedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_BACKUP_CREATED); + insert(auditLogEntry); + } + + public void addAndroidBackupCreatedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_ANDROID_BACKUP_CREATED); + insert(auditLogEntry); + } + + public void addVaultExportedEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_EXPORTED); + insert(auditLogEntry); + } + + public void addEntrySharedEvent(String reference) { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.ENTRY_SHARED, reference); + insert(auditLogEntry); + } + + public void addVaultUnlockFailedPasswordEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCK_FAILED_PASSWORD); + insert(auditLogEntry); + + } + + public void addVaultUnlockFailedBiometricsEvent() { + AuditLogEntry auditLogEntry = new AuditLogEntry(EventType.VAULT_UNLOCK_FAILED_BIOMETRICS); + insert(auditLogEntry); + } + + public void insert(AuditLogEntry auditLogEntry) { + _executor.execute(() -> { + _auditLogDao.insert(auditLogEntry); + }); + } + +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java new file mode 100644 index 0000000..85fe61f --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java new file mode 100644 index 0000000..b6dbe4b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java new file mode 100644 index 0000000..f85ec0e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/encoding/Hex.java b/app/src/main/java/com/beemdevelopment/aegis/encoding/Hex.java new file mode 100644 index 0000000..506a927 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/encoding/Hex.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java new file mode 100644 index 0000000..98d8c36 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java @@ -0,0 +1,54 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.Context; +import android.provider.Settings; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.view.animation.LayoutAnimationController; + +public class AnimationsHelper { + private AnimationsHelper() { + + } + + public static Animation loadScaledAnimation(Context context, int animationResId) { + return loadScaledAnimation(context, animationResId, Scale.ANIMATOR); + } + + public static Animation loadScaledAnimation(Context context, int animationResId, Scale scale) { + Animation animation = AnimationUtils.loadAnimation(context, animationResId); + long newDuration = (long) (animation.getDuration() * scale.getValue(context)); + animation.setDuration(newDuration); + return animation; + } + + public static LayoutAnimationController loadScaledLayoutAnimation(Context context, int animationResId) { + return loadScaledLayoutAnimation(context, animationResId, Scale.ANIMATOR); + } + + public static LayoutAnimationController loadScaledLayoutAnimation(Context context, int animationResId, Scale scale) { + LayoutAnimationController controller = AnimationUtils.loadLayoutAnimation(context, animationResId); + Animation animation = controller.getAnimation(); + animation.setDuration((long) (animation.getDuration() * scale.getValue(context))); + return controller; + } + + public enum Scale { + ANIMATOR(Settings.Global.ANIMATOR_DURATION_SCALE), + TRANSITION(Settings.Global.TRANSITION_ANIMATION_SCALE); + + private final String _setting; + + Scale(String setting) { + _setting = setting; + } + + public float getValue(Context context) { + return Settings.Global.getFloat(context.getContentResolver(), _setting, 1.0f); + } + + public boolean isZero(Context context) { + return getValue(context) == 0; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricSlotInitializer.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricSlotInitializer.java new file mode 100644 index 0000000..45dd33a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricSlotInitializer.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricsHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricsHelper.java new file mode 100644 index 0000000..99b2a9a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricsHelper.java @@ -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; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java new file mode 100644 index 0000000..339c176 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java new file mode 100644 index 0000000..bc94a21 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java @@ -0,0 +1,30 @@ +package com.beemdevelopment.aegis.helpers; + +import android.graphics.Rect; +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +public class CenterVerticalSpan extends MetricAffectingSpan { + Rect _substringBounds; + + public CenterVerticalSpan(Rect substringBounds) { + _substringBounds = substringBounds; + } + + @Override + public void updateMeasureState(@NonNull TextPaint textPaint) { + applyBaselineShift(textPaint); + } + + @Override + public void updateDrawState(@NonNull TextPaint textPaint) { + applyBaselineShift(textPaint); + } + + private void applyBaselineShift(TextPaint textPaint) { + float topDifference = textPaint.getFontMetrics().top - _substringBounds.top; + textPaint.baselineShift -= (topDifference / 2f); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/ContextHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/ContextHelper.java new file mode 100644 index 0000000..2eaad2c --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/ContextHelper.java @@ -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(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/DropdownHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/DropdownHelper.java new file mode 100644 index 0000000..34414e3 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/DropdownHelper.java @@ -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 adapter = ArrayAdapter.createFromResource(context, textArrayResId, R.layout.dropdown_list_item); + dropdown.setAdapter(adapter); + } + + public static void fillDropdown(Context context, AutoCompleteTextView dropdown, List items) { + ArrayAdapter adapter = new ArrayAdapter<>(context, R.layout.dropdown_list_item, items); + dropdown.setAdapter(adapter); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/EditTextHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/EditTextHelper.java new file mode 100644 index 0000000..cc94670 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/EditTextHelper.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java new file mode 100644 index 0000000..6092a0e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java @@ -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(); + } + + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/ItemTouchHelperAdapter.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/ItemTouchHelperAdapter.java new file mode 100644 index 0000000..d654c27 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/ItemTouchHelperAdapter.java @@ -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 not at the end of a "drop" event.
+ *
+ * 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.
+ *
+ * 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); +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/MetricsHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/MetricsHelper.java new file mode 100644 index 0000000..6bcc3c4 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/MetricsHelper.java @@ -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)); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java new file mode 100644 index 0000000..373ef11 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java @@ -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]; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/PermissionHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/PermissionHelper.java new file mode 100644 index 0000000..5be0ab0 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/PermissionHelper.java @@ -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 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; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java new file mode 100644 index 0000000..2fd1b21 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java @@ -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); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java new file mode 100644 index 0000000..37291e9 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java @@ -0,0 +1,96 @@ +package com.beemdevelopment.aegis.helpers; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; + +import androidx.annotation.ColorInt; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.DecodeHintType; +import com.google.zxing.LuminanceSource; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.NotFoundException; +import com.google.zxing.RGBLuminanceSource; +import com.google.zxing.Result; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeWriter; + +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class QrCodeHelper { + private QrCodeHelper() { + + } + + public static Result decodeFromSource(LuminanceSource source) throws NotFoundException { + Map hints = new HashMap<>(); + hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); + hints.put(DecodeHintType.ALSO_INVERTED, true); + + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + MultiFormatReader reader = new MultiFormatReader(); + return reader.decode(bitmap, hints); + } + + public static Result decodeFromStream(InputStream inStream) throws DecodeError { + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + Bitmap bitmap = BitmapFactory.decodeStream(inStream, null, bmOptions); + if (bitmap == null) { + throw new DecodeError("Unable to decode stream to bitmap"); + } + + // If ZXing is not able to decode the image on the first try, we try a couple of + // more times with smaller versions of the same image. + for (int i = 0; i <= 2; i++) { + if (i != 0) { + bitmap = BitmapHelper.resize(bitmap, bitmap.getWidth() / (i * 2), bitmap.getHeight() / (i * 2)); + } + + try { + int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()]; + bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); + + LuminanceSource source = new RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), pixels); + return decodeFromSource(source); + } catch (NotFoundException ignored) { + + } + } + + throw new DecodeError(NotFoundException.getNotFoundInstance()); + } + + public static Bitmap encodeToBitmap(String data, int width, int height, @ColorInt int backgroundColor) throws WriterException { + QRCodeWriter writer = new QRCodeWriter(); + BitMatrix bitMatrix = writer.encode(data, BarcodeFormat.QR_CODE, width, height); + + int[] pixels = new int[width * height]; + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + public static class DecodeError extends Exception { + public DecodeError(String message) { + super(message); + } + + public DecodeError(Throwable cause) { + super(cause); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java new file mode 100644 index 0000000..38828b2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java @@ -0,0 +1,47 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import androidx.documentfile.provider.DocumentFile; + +public class SafHelper { + private SafHelper() { + + } + + public static String getFileName(Context context, Uri uri) { + if (uri.getScheme() != null && uri.getScheme().equals("content")) { + try (Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int i = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (i != -1) { + return cursor.getString(i); + } + } + } + } + + return uri.getLastPathSegment(); + } + + public static String getMimeType(Context context, Uri uri) { + DocumentFile file = DocumentFile.fromSingleUri(context, uri); + if (file != null) { + String fileType = file.getType(); + if (fileType != null) { + return fileType; + } + + String ext = MimeTypeMap.getFileExtensionFromUrl(uri.toString()); + if (ext != null) { + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext); + } + } + + return null; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java new file mode 100644 index 0000000..f41a9c9 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java @@ -0,0 +1,32 @@ +package com.beemdevelopment.aegis.helpers; + +import android.view.animation.Animation; + +public class SimpleAnimationEndListener implements Animation.AnimationListener { + private final Listener _listener; + + public SimpleAnimationEndListener(Listener listener) { + _listener = listener; + } + + @Override + public void onAnimationStart(Animation animation) { + + } + + @Override + public void onAnimationEnd(Animation animation) { + if (_listener != null) { + _listener.onAnimationEnd(animation); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + + } + + public interface Listener { + void onAnimationEnd(Animation animation); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java new file mode 100644 index 0000000..96c672b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java @@ -0,0 +1,111 @@ +package com.beemdevelopment.aegis.helpers; + +import static androidx.recyclerview.widget.RecyclerView.NO_POSITION; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.ui.views.EntryAdapter; +import com.beemdevelopment.aegis.vault.VaultEntry; + +public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback { + + private VaultEntry _selectedEntry; + + private final EntryAdapter _adapter; + private boolean _positionChanged = false; + private boolean _isLongPressDragEnabled = true; + private int _dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + + public SimpleItemTouchHelperCallback(EntryAdapter adapter) { + _adapter = adapter; + } + + @Override + public boolean isLongPressDragEnabled() { + return _isLongPressDragEnabled; + } + + public void setIsLongPressDragEnabled(boolean enabled) { + _isLongPressDragEnabled = enabled; + } + + public void setSelectedEntry(VaultEntry entry) { + if (entry == null) { + _selectedEntry = null; + return; + } + + if (!entry.isFavorite()) { + _selectedEntry = entry; + } + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + public void setDragFlags(int dragFlags) { + _dragFlags = dragFlags; + } + + @Override + public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + // It's not clear when this can happen, but sometimes the ViewHolder + // that's passed to this function has a position of -1, leading + // to a crash down the line. + int position = viewHolder.getBindingAdapterPosition(); + if (position == NO_POSITION) { + return 0; + } + + EntryAdapter adapter = (EntryAdapter) recyclerView.getAdapter(); + if (adapter == null) { + return 0; + } + + int swipeFlags = 0; + if (adapter.isPositionFooter(position) + || adapter.isPositionErrorCard(position) + || adapter.getEntryAtPosition(position) != _selectedEntry + || !isLongPressDragEnabled()) { + return makeMovementFlags(0, swipeFlags); + } + + return makeMovementFlags(_dragFlags, swipeFlags); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, + RecyclerView.ViewHolder target) { + int targetIndex = _adapter.translateEntryPosToIndex(target.getBindingAdapterPosition()); + if (targetIndex < _adapter.getShownFavoritesCount()) { + return false; + } + + int firstPosition = viewHolder.getLayoutPosition(); + int secondPosition = target.getBindingAdapterPosition(); + + _adapter.onItemMove(firstPosition, secondPosition); + _positionChanged = true; + return true; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { + _adapter.onItemDismiss(viewHolder.getBindingAdapterPosition()); + } + + @Override + public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + + if (_positionChanged) { + _adapter.onItemDrop(viewHolder.getBindingAdapterPosition()); + _positionChanged = false; + _adapter.refresh(false); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java new file mode 100644 index 0000000..3b5d616 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java @@ -0,0 +1,33 @@ +package com.beemdevelopment.aegis.helpers; + +import android.text.Editable; +import android.text.TextWatcher; + +public final class SimpleTextWatcher implements TextWatcher { + private final Listener _listener; + + public SimpleTextWatcher(Listener listener) { + _listener = listener; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + + } + + @Override + public void afterTextChanged(Editable s) { + if (_listener != null) { + _listener.afterTextChanged(s); + } + } + + public interface Listener { + void afterTextChanged(Editable s); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java new file mode 100644 index 0000000..fc66b0c --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java @@ -0,0 +1,66 @@ +package com.beemdevelopment.aegis.helpers; + +import android.view.View; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.amulyakhare.textdrawable.util.ColorGenerator; + +import java.text.BreakIterator; +import java.util.Arrays; + +public class TextDrawableHelper { + // taken from: https://materialuicolors.co (level 700) + private static ColorGenerator _generator = ColorGenerator.create(Arrays.asList( + 0xFFD32F2F, + 0xFFC2185B, + 0xFF7B1FA2, + 0xFF512DA8, + 0xFF303F9F, + 0xFF1976D2, + 0xFF0288D1, + 0xFF0097A7, + 0xFF00796B, + 0xFF388E3C, + 0xFF689F38, + 0xFFAFB42B, + 0xFFFBC02D, + 0xFFFFA000, + 0xFFF57C00, + 0xFFE64A19, + 0xFF5D4037, + 0xFF616161, + 0xFF455A64 + )); + + private TextDrawableHelper() { + + } + + public static TextDrawable generate(String text, String fallback, View view) { + if (text == null || text.isEmpty()) { + if (fallback == null || fallback.isEmpty()) { + return null; + } + text = fallback; + } + + int color = _generator.getColor(text); + return TextDrawable.builder().beginConfig() + .width(view.getLayoutParams().width) + .height(view.getLayoutParams().height) + .endConfig() + .buildRound(getFirstGrapheme(text).toUpperCase(), color); + } + + private static String getFirstGrapheme(String text) { + BreakIterator iter = BreakIterator.getCharacterInstance(); + iter.setText(text); + + int start = iter.first(), end = iter.next(); + if (end == BreakIterator.DONE) { + return ""; + } + + return text.substring(start, end); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java new file mode 100644 index 0000000..0bd1dcf --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java @@ -0,0 +1,58 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.res.Configuration; + +import androidx.appcompat.app.AppCompatActivity; + +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.Theme; +import com.google.android.material.color.DynamicColors; +import com.google.android.material.color.DynamicColorsOptions; + +import java.util.Map; + +public class ThemeHelper { + private final AppCompatActivity _activity; + private final Preferences _prefs; + + public ThemeHelper(AppCompatActivity activity, Preferences prefs) { + _activity = activity; + _prefs = prefs; + } + + /** + * Sets the theme of the activity. The actual style that is set is picked from the + * given map, based on the theme configured by the user. + */ + public void setTheme(Map themeMap) { + int theme = themeMap.get(getConfiguredTheme()); + _activity.setTheme(theme); + + if (_prefs.isDynamicColorsEnabled()) { + DynamicColorsOptions.Builder optsBuilder = new DynamicColorsOptions.Builder(); + if (getConfiguredTheme().equals(Theme.AMOLED)) { + optsBuilder.setThemeOverlay(R.style.ThemeOverlay_Aegis_Dynamic_Amoled); + } else if (getConfiguredTheme().equals(Theme.DARK)) { + optsBuilder.setThemeOverlay(R.style.ThemeOverlay_Aegis_Dynamic_Dark); + } + + DynamicColors.applyToActivityIfAvailable(_activity, optsBuilder.build()); + } + } + + public Theme getConfiguredTheme() { + Theme theme = _prefs.getCurrentTheme(); + + if (theme == Theme.SYSTEM || theme == Theme.SYSTEM_AMOLED) { + int currentNightMode = _activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + if (currentNightMode == Configuration.UI_MODE_NIGHT_YES) { + theme = theme == Theme.SYSTEM_AMOLED ? Theme.AMOLED : Theme.DARK; + } else { + theme = Theme.LIGHT; + } + } + + return theme; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java new file mode 100644 index 0000000..c7d0425 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java @@ -0,0 +1,69 @@ +package com.beemdevelopment.aegis.helpers; + +import android.os.Handler; + +import com.beemdevelopment.aegis.VibrationPatterns; + +public class UiRefresher { + private boolean _running; + private Listener _listener; + private Handler _handler; + + public UiRefresher(Listener listener) { + _listener = listener; + _handler = new Handler(); + } + + public void destroy() { + stop(); + _listener = null; + } + + public void start() { + if (_running) { + return; + } + _running = true; + + _handler.postDelayed(new Runnable() { + @Override + public void run() { + _listener.onRefresh(); + _handler.postDelayed(this, _listener.getMillisTillNextRefresh()); + } + }, _listener.getMillisTillNextRefresh()); + + _handler.postDelayed(new Runnable() { + @Override + public void run() { + _listener.onExpiring(); + _handler.postDelayed(this, getNextRun()); + } + }, getInitialRun()); + } + + private long getInitialRun() { + long sum = _listener.getMillisTillNextRefresh() - VibrationPatterns.getLengthInMillis(VibrationPatterns.EXPIRING); + if (sum < 0) { + return getNextRun(); + } + + return sum; + } + + private long getNextRun() { + return (_listener.getMillisTillNextRefresh() + _listener.getPeriodMillis()) - VibrationPatterns.getLengthInMillis(VibrationPatterns.EXPIRING); + } + + public void stop() { + _handler.removeCallbacksAndMessages(null); + _running = false; + } + + public interface Listener { + void onRefresh(); + void onExpiring(); + long getMillisTillNextRefresh(); + long getPeriodMillis(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/UiThreadExecutor.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/UiThreadExecutor.java new file mode 100644 index 0000000..4cb0aa5 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/UiThreadExecutor.java @@ -0,0 +1,17 @@ +package com.beemdevelopment.aegis.helpers; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; + +import java.util.concurrent.Executor; + +public class UiThreadExecutor implements Executor { + private final Handler _handler = new Handler(Looper.getMainLooper()); + + @Override + public void execute(@NonNull Runnable command) { + _handler.post(command); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java new file mode 100644 index 0000000..9fbc7a0 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java @@ -0,0 +1,44 @@ +package com.beemdevelopment.aegis.helpers; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; + +import com.beemdevelopment.aegis.Preferences; + +public class VibrationHelper { + private Preferences _preferences; + + public VibrationHelper(Context context) { + _preferences = new Preferences(context); + } + + public void vibratePattern(Context context, long[] pattern) { + if (!isHapticFeedbackEnabled()) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager vibratorManager = (VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); + if (vibratorManager != null) { + Vibrator vibrator = vibratorManager.getDefaultVibrator(); + VibrationEffect effect = VibrationEffect.createWaveform(pattern, -1); + vibrator.vibrate(effect); + } + } else { + Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + if (vibrator != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + VibrationEffect effect = VibrationEffect.createWaveform(pattern, -1); + vibrator.vibrate(effect); + } + } + } + } + + public boolean isHapticFeedbackEnabled() { + return _preferences.isHapticFeedbackEnabled(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java new file mode 100644 index 0000000..910aae2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java @@ -0,0 +1,26 @@ +package com.beemdevelopment.aegis.helpers; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.google.android.material.appbar.AppBarLayout; + +public class ViewHelper { + private ViewHelper() { + + } + + public static void setupAppBarInsets(AppBarLayout appBar) { + ViewCompat.setOnApplyWindowInsetsListener(appBar, (targetView, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + targetView.setPadding( + insets.left, + insets.top, + insets.right, + 0 + ); + return WindowInsetsCompat.CONSUMED; + }); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/AccountNameComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/AccountNameComparator.java new file mode 100644 index 0000000..fbd2cca --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/AccountNameComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class AccountNameComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return a.getName().compareToIgnoreCase(b.getName()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java new file mode 100644 index 0000000..fc16f00 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class FavoriteComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return -1 * Boolean.compare(a.isFavorite(), b.isFavorite()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/IssuerNameComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/IssuerNameComparator.java new file mode 100644 index 0000000..b403265 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/IssuerNameComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class IssuerNameComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return a.getIssuer().compareToIgnoreCase(b.getIssuer()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java new file mode 100644 index 0000000..5fc85ed --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class LastUsedComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return Long.compare(a.getLastUsedTimestamp(), b.getLastUsedTimestamp()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/UsageCountComparator.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/UsageCountComparator.java new file mode 100644 index 0000000..d3f1ba2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/UsageCountComparator.java @@ -0,0 +1,12 @@ +package com.beemdevelopment.aegis.helpers.comparators; + +import com.beemdevelopment.aegis.vault.VaultEntry; + +import java.util.Comparator; + +public class UsageCountComparator implements Comparator { + @Override + public int compare(VaultEntry a, VaultEntry b) { + return Integer.compare(a.getUsageCount(), b.getUsageCount()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java new file mode 100644 index 0000000..5f87ca5 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java @@ -0,0 +1,216 @@ +package com.beemdevelopment.aegis.icons; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.util.JsonUtils; +import com.google.common.base.Objects; +import com.google.common.io.Files; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class IconPack { + private UUID _uuid; + private String _name; + private int _version; + private List _icons; + + private File _dir; + + private IconPack(UUID uuid, String name, int version, List icons) { + _uuid = uuid; + _name = name; + _version = version; + _icons = icons; + } + + public UUID getUUID() { + return _uuid; + } + + public String getName() { + return _name; + } + + public int getVersion() { + return _version; + } + + public List getIcons() { + return Collections.unmodifiableList(_icons); + } + + /** + * Retrieves a list of icons suggested for the given issuer. + */ + public List getSuggestedIcons(String issuer) { + if (issuer == null || issuer.isEmpty()) { + return new ArrayList<>(); + } + + List icons = new ArrayList<>(); + for (Icon icon : _icons) { + MatchType matchType = icon.getMatchFor(issuer); + if (matchType != null) { + // Inverse matches (entry issuer contains icon name) are less likely + // to be good, so position them at the end of the list. + if (matchType.equals(MatchType.NORMAL)) { + icons.add(0, icon); + } else if (matchType.equals(MatchType.INVERSE)) { + icons.add(icon); + } + } + } + + return icons; + } + + @Nullable + public File getDirectory() { + return _dir; + } + + void setDirectory(@NonNull File dir) { + _dir = dir; + } + + /** + * Indicates whether some other object is "equal to" this one. The object does not + * necessarily have to be the same instance. Equality of UUID and version will make + * this method return true; + */ + @Override + public boolean equals(Object o) { + if (!(o instanceof IconPack)) { + return false; + } + + IconPack pack = (IconPack) o; + return super.equals(pack) || (getUUID().equals(pack.getUUID()) && getVersion() == pack.getVersion()); + } + + @Override + public int hashCode() { + return Objects.hashCode(_uuid, _version); + } + + public static IconPack fromJson(JSONObject obj) throws JSONException { + UUID uuid; + String uuidString = obj.getString("uuid"); + try { + uuid = UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + throw new JSONException(String.format("Bad UUID format: %s", uuidString)); + } + String name = obj.getString("name"); + int version = obj.getInt("version"); + JSONArray array = obj.getJSONArray("icons"); + + List icons = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + Icon icon = Icon.fromJson(array.getJSONObject(i)); + icons.add(icon); + } + + return new IconPack(uuid, name, version, icons); + } + + public static IconPack fromBytes(byte[] data) throws JSONException { + JSONObject obj = new JSONObject(new String(data, StandardCharsets.UTF_8)); + return IconPack.fromJson(obj); + } + + public static class Icon implements Serializable { + private final String _relFilename; + private final String _name; + private final String _category; + private final List _issuers; + + private File _file; + + protected Icon(String filename, String name, String category, List issuers) { + _relFilename = filename; + _name = name; + _category = category; + _issuers = issuers; + } + + public String getRelativeFilename() { + return _relFilename; + } + + @Nullable + public File getFile() { + return _file; + } + + void setFile(@NonNull File file) { + _file = file; + } + + public IconType getIconType() { + return IconType.fromFilename(_relFilename); + } + + public String getName() { + if (_name != null) { + return _name; + } + return Files.getNameWithoutExtension(new File(_relFilename).getName()); + } + + public String getCategory() { + return _category; + } + + private MatchType getMatchFor(String issuer) { + String lowerEntryIssuer = issuer.toLowerCase(); + + boolean inverseMatch = false; + for (String is : _issuers) { + String lowerIconIssuer = is.toLowerCase(); + if (lowerIconIssuer.contains(lowerEntryIssuer)) { + return MatchType.NORMAL; + } + if (lowerEntryIssuer.contains(lowerIconIssuer)) { + inverseMatch = true; + } + } + if (inverseMatch) { + return MatchType.INVERSE; + } + + return null; + } + + public static Icon fromJson(JSONObject obj) throws JSONException { + String filename = obj.getString("filename"); + String name = JsonUtils.optString(obj, "name"); + String category = obj.isNull("category") ? null : obj.getString("category"); + JSONArray array = obj.getJSONArray("issuer"); + + List issuers = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + String issuer = array.getString(i); + issuers.add(issuer); + } + + return new Icon(filename, name, category, issuers); + } + } + + private enum MatchType { + NORMAL, + INVERSE + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java new file mode 100644 index 0000000..989faf4 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java @@ -0,0 +1,11 @@ +package com.beemdevelopment.aegis.icons; + +public class IconPackException extends Exception { + public IconPackException(Throwable cause) { + super(cause); + } + + public IconPackException(String message) { + super(message); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java new file mode 100644 index 0000000..5009f73 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java @@ -0,0 +1,14 @@ +package com.beemdevelopment.aegis.icons; + +public class IconPackExistsException extends IconPackException { + private IconPack _pack; + + public IconPackExistsException(IconPack pack) { + super(String.format("Icon pack %s (%d) already exists", pack.getName(), pack.getVersion())); + _pack = pack; + } + + public IconPack getIconPack() { + return _pack; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java new file mode 100644 index 0000000..7aa9a29 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java @@ -0,0 +1,223 @@ +package com.beemdevelopment.aegis.icons; + +import android.content.Context; + +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.util.IOUtils; + +import net.lingala.zip4j.ZipFile; +import net.lingala.zip4j.io.inputstream.ZipInputStream; +import net.lingala.zip4j.model.FileHeader; + +import org.json.JSONException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +public class IconPackManager { + private static final String _packDefFilename = "pack.json"; + + private File _iconsBaseDir; + private List _iconPacks; + + public IconPackManager(Context context) { + _iconPacks = new ArrayList<>(); + _iconsBaseDir = new File(context.getFilesDir(), "icons"); + rescanIconPacks(); + } + + private IconPack getIconPackByUUID(UUID uuid) { + List packs = _iconPacks.stream().filter(i -> i.getUUID().equals(uuid)).collect(Collectors.toList()); + if (packs.size() == 0) { + return null; + } + + return packs.get(0); + } + + public boolean hasIconPack() { + return _iconPacks.size() > 0; + } + + public List getIconPacks() { + return new ArrayList<>(_iconPacks); + } + + public void removeIconPack(IconPack pack) throws IconPackException { + try { + File dir = getIconPackDir(pack); + deleteDir(dir); + } catch (IOException e) { + throw new IconPackException(e); + } + + _iconPacks.remove(pack); + } + + public IconPack importPack(File inFile) throws IconPackException { + try { + // read and parse the icon pack definition file of the icon pack + ZipFile zipFile = new ZipFile(inFile); + FileHeader packHeader = zipFile.getFileHeader(_packDefFilename); + if (packHeader == null) { + throw new IOException("Unable to find pack.json in the root of the ZIP file"); + } + IconPack pack; + byte[] defBytes; + try (ZipInputStream inStream = zipFile.getInputStream(packHeader)) { + defBytes = IOUtils.readAll(inStream); + pack = IconPack.fromBytes(defBytes); + } + + // create a new directory to store the icon pack, based on the UUID and version + File packDir = getIconPackDir(pack); + if (!packDir.getCanonicalPath().startsWith(_iconsBaseDir.getCanonicalPath() + File.separator)) { + throw new IOException("Attempted to write outside of the parent directory"); + } + if (packDir.exists()) { + throw new IconPackExistsException(pack); + } + IconPack existingPack = getIconPackByUUID(pack.getUUID()); + if (existingPack != null) { + throw new IconPackExistsException(existingPack); + } + if (!packDir.exists() && !packDir.mkdirs()) { + throw new IOException(String.format("Unable to create directories: %s", packDir.toString())); + } + + // extract each of the defined icons to the icon pack directory + for (IconPack.Icon icon : pack.getIcons()) { + File destFile = new File(packDir, icon.getRelativeFilename()); + FileHeader iconHeader = zipFile.getFileHeader(icon.getRelativeFilename()); + if (iconHeader == null) { + throw new IOException(String.format("Unable to find %s relative to the root of the ZIP file", icon.getRelativeFilename())); + } + + // create new directories for this file if needed + File parent = destFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException(String.format("Unable to create directories: %s", packDir.toString())); + } + + try (ZipInputStream inStream = zipFile.getInputStream(iconHeader); + FileOutputStream outStream = new FileOutputStream(destFile)) { + IOUtils.copy(inStream, outStream); + } + + // after successful copy of the icon, store the new filename + icon.setFile(destFile); + } + + // write the icon pack definition file to the newly created directory + try (FileOutputStream outStream = new FileOutputStream(new File(packDir, _packDefFilename))) { + outStream.write(defBytes); + } + + // after successful extraction of the icon pack, store the new directory + pack.setDirectory(packDir); + _iconPacks.add(pack); + return pack; + } catch (IOException | JSONException e) { + throw new IconPackException(e); + } + } + + private void rescanIconPacks() { + _iconPacks.clear(); + + File[] dirs = _iconsBaseDir.listFiles(); + if (dirs == null) { + return; + } + + for (File dir : dirs) { + if (!dir.isDirectory()) { + continue; + } + + UUID uuid; + try { + uuid = UUID.fromString(dir.getName()); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + continue; + } + + File versionDir = getLatestVersionDir(dir); + if (versionDir != null) { + IconPack pack; + try (FileInputStream inStream = new FileInputStream(new File(versionDir, _packDefFilename))) { + byte[] bytes = IOUtils.readAll(inStream); + pack = IconPack.fromBytes(bytes); + pack.setDirectory(versionDir); + } catch (JSONException | IOException e) { + e.printStackTrace(); + continue; + } + + for (IconPack.Icon icon : pack.getIcons()) { + icon.setFile(new File(versionDir, icon.getRelativeFilename())); + } + + // do a sanity check on the UUID and version + if (pack.getUUID().equals(uuid) && pack.getVersion() == Integer.parseInt(versionDir.getName())) { + _iconPacks.add(pack); + } + } + } + } + + private File getIconPackDir(IconPack pack) { + return new File(_iconsBaseDir, pack.getUUID() + File.separator + pack.getVersion()); + } + + @Nullable + private static File getLatestVersionDir(File packDir) { + File[] dirs = packDir.listFiles(); + if (dirs == null) { + return null; + } + + int latestVersion = -1; + for (File versionDir : dirs) { + int version; + try { + version = Integer.parseInt(versionDir.getName()); + } catch (NumberFormatException ignored) { + continue; + } + + if (latestVersion == -1 || version > latestVersion) { + latestVersion = version; + } + } + + if (latestVersion == -1) { + return null; + } + + return new File(packDir, Integer.toString(latestVersion)); + } + + private static void deleteDir(File dir) throws IOException { + if (dir.isDirectory()) { + File[] children = dir.listFiles(); + if (children != null) { + for (File child : children) { + deleteDir(child); + } + } + } + + if (!dir.delete()) { + throw new IOException(String.format("Unable to delete directory: %s", dir)); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java new file mode 100644 index 0000000..c742ecc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java @@ -0,0 +1,53 @@ +package com.beemdevelopment.aegis.icons; + +import com.google.common.io.Files; + +import java.util.Locale; + +public enum IconType { + INVALID, + SVG, + PNG, + JPEG; + + public static IconType fromMimeType(String mimeType) { + switch (mimeType) { + case "image/svg+xml": + return SVG; + case "image/png": + return PNG; + case "image/jpeg": + return JPEG; + default: + return INVALID; + } + } + + public static IconType fromFilename(String filename) { + switch (Files.getFileExtension(filename).toLowerCase(Locale.ROOT)) { + case "svg": + return SVG; + case "png": + return PNG; + case "jpg": + // intentional fallthrough + case "jpeg": + return JPEG; + default: + return INVALID; + } + } + + public String toMimeType() { + switch (this) { + case SVG: + return "image/svg+xml"; + case PNG: + return "image/png"; + case JPEG: + return "image/jpeg"; + default: + throw new RuntimeException(String.format("Can't convert icon type %s to MIME type", this)); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java new file mode 100644 index 0000000..ac42cc8 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java @@ -0,0 +1,190 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.DialogInterface; + +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.ContextHelper; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.PasswordSlotDecryptTask; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryException; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultFileCredentials; +import com.beemdevelopment.aegis.vault.VaultFileException; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; +import com.beemdevelopment.aegis.vault.slots.SlotList; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.UUID; + +public class AegisImporter extends DatabaseImporter { + + public AegisImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + byte[] bytes = IOUtils.readAll(stream); + VaultFile file = VaultFile.fromBytes(bytes); + if (file.isEncrypted()) { + return new EncryptedState(file); + } + return new DecryptedState(file.getContent()); + } catch (VaultFileException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class EncryptedState extends State { + private VaultFile _file; + + private EncryptedState(VaultFile file) { + super(true); + _file = file; + } + + public SlotList getSlots() { + return _file.getHeader().getSlots(); + } + + public State decrypt(VaultFileCredentials creds) throws DatabaseImporterException { + JSONObject obj; + try { + obj = _file.getContent(creds); + } catch (VaultFileException e) { + throw new DatabaseImporterException(e); + } + + return new DecryptedState(obj, creds); + } + + public State decrypt(char[] password) throws DatabaseImporterException { + List slots = getSlots().findAll(PasswordSlot.class); + PasswordSlotDecryptTask.Result result = PasswordSlotDecryptTask.decrypt(slots, password); + VaultFileCredentials creds = new VaultFileCredentials(result.getKey(), getSlots()); + return decrypt(creds); + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> { + List slots = getSlots().findAll(PasswordSlot.class); + PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password); + PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(context, result -> { + try { + if (result == null) { + throw new DatabaseImporterException("Password incorrect"); + } + + VaultFileCredentials creds = new VaultFileCredentials(result.getKey(), getSlots()); + State state = decrypt(creds); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, (DialogInterface.OnCancelListener) dialog -> listener.onCanceled()); + } + } + + public static class DecryptedState extends State { + private JSONObject _obj; + private VaultFileCredentials _creds; + + private DecryptedState(JSONObject obj) { + this(obj, null); + } + + private DecryptedState(JSONObject obj, VaultFileCredentials creds) { + super(false); + _obj = obj; + _creds = creds; + } + + @Nullable + public VaultFileCredentials getCredentials() { + return _creds; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = new Result(); + + try { + if (_obj.has("groups")) { + JSONArray groupArray = _obj.getJSONArray("groups"); + for (int i = 0; i < groupArray.length(); i++) { + JSONObject groupObj = groupArray.getJSONObject(i); + try { + VaultGroup group = convertGroup(groupObj); + if (!result.getGroups().has(group)) { + result.addGroup(group); + } + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } + + JSONArray entryArray = _obj.getJSONArray("entries"); + for (int i = 0; i < entryArray.length(); i++) { + JSONObject entryObj = entryArray.getJSONObject(i); + try { + VaultEntry entry = convertEntry(entryObj); + for (UUID groupUuid : entry.getGroups()) { + if (!result.getGroups().has(groupUuid)) { + entry.getGroups().remove(groupUuid); + } + } + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + return VaultEntry.fromJson(obj); + } catch (VaultEntryException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + + private static VaultGroup convertGroup(JSONObject obj) throws DatabaseImporterEntryException { + try { + return VaultGroup.fromJson(obj); + } catch (VaultEntryException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java new file mode 100644 index 0000000..2be31b0 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java @@ -0,0 +1,275 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptParameters; +import com.beemdevelopment.aegis.crypto.CryptResult; +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.helpers.ContextHelper; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Locale; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class AndOtpImporter extends DatabaseImporter { + private static final int INT_SIZE = 4; + private static final int NONCE_SIZE = 12; + private static final int TAG_SIZE = 16; + private static final int SALT_SIZE = 12; + private static final int KEY_SIZE = 256; // bits + + public AndOtpImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + byte[] bytes; + try { + bytes = IOUtils.readAll(stream); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + + try { + return read(bytes); + } catch (JSONException e) { + // andOTP doesn't have a proper way to indicate whether a file is encrypted + // so, if we can't parse it as JSON, we'll have to assume it is + return new EncryptedState(bytes); + } + } + + private static DecryptedState read(byte[] bytes) throws JSONException { + JSONArray array = new JSONArray(new String(bytes, StandardCharsets.UTF_8)); + return new DecryptedState(array); + } + + public static class EncryptedState extends DatabaseImporter.State { + private byte[] _data; + + public EncryptedState(byte[] data) { + super(true); + _data = data; + } + + private DecryptedState decryptContent(SecretKey key, int offset) throws DatabaseImporterException { + byte[] nonce = Arrays.copyOfRange(_data, offset, offset + NONCE_SIZE); + byte[] tag = Arrays.copyOfRange(_data, _data.length - TAG_SIZE, _data.length); + CryptParameters params = new CryptParameters(nonce, tag); + + try { + Cipher cipher = CryptoUtils.createDecryptCipher(key, nonce); + int len = _data.length - offset - NONCE_SIZE - TAG_SIZE; + CryptResult result = CryptoUtils.decrypt(_data, offset + NONCE_SIZE, len, cipher, params); + return read(result.getData()); + } catch (IOException | BadPaddingException | JSONException e) { + throw new DatabaseImporterException(e); + } catch (NoSuchAlgorithmException + | InvalidAlgorithmParameterException + | InvalidKeyException + | NoSuchPaddingException + | IllegalBlockSizeException e) { + throw new RuntimeException(e); + } + } + + private PBKDFTask.Params getKeyDerivationParams(char[] password) throws DatabaseImporterException { + byte[] iterBytes = Arrays.copyOfRange(_data, 0, INT_SIZE); + int iterations = ByteBuffer.wrap(iterBytes).getInt(); + if (iterations < 1) { + throw new DatabaseImporterException(String.format("Invalid number of iterations for PBKDF: %d", iterations)); + } + // If number of iterations is this high, it's probably not an andOTP file, so + // abort early in order to prevent having to wait for an extremely long key derivation + // process, only to find out that the user picked the wrong file + if (iterations > 10_000_000L) { + throw new DatabaseImporterException(String.format("Unexpectedly high number of iterations: %d", iterations)); + } + + byte[] salt = Arrays.copyOfRange(_data, INT_SIZE, INT_SIZE + SALT_SIZE); + return new PBKDFTask.Params("PBKDF2WithHmacSHA1", KEY_SIZE, password, salt, iterations); + } + + protected DecryptedState decryptOldFormat(char[] password) throws DatabaseImporterException { + // WARNING: DON'T DO THIS IN YOUR OWN CODE + // this exists solely to support the old andOTP backup format + // it is not a secure way to derive a key from a password + MessageDigest hash; + try { + hash = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + byte[] keyBytes = hash.digest(CryptoUtils.toBytes(password)); + SecretKey key = new SecretKeySpec(keyBytes, "AES"); + return decryptContent(key, 0); + } + + protected DecryptedState decryptNewFormat(SecretKey key) throws DatabaseImporterException { + return decryptContent(key, INT_SIZE + SALT_SIZE); + } + + protected DecryptedState decryptNewFormat(char[] password) + throws DatabaseImporterException { + PBKDFTask.Params params = getKeyDerivationParams(password); + SecretKey key = PBKDFTask.deriveKey(params); + return decryptNewFormat(key); + } + + private void decrypt(Context context, char[] password, boolean oldFormat, DecryptListener listener) throws DatabaseImporterException { + if (oldFormat) { + DecryptedState state = decryptOldFormat(password); + listener.onStateDecrypted(state); + } else { + PBKDFTask.Params params = getKeyDerivationParams(password); + PBKDFTask task = new PBKDFTask(context, key -> { + try { + DecryptedState state = decryptNewFormat(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + String[] choices = new String[]{ + context.getResources().getString(R.string.andotp_new_format), + context.getResources().getString(R.string.andotp_old_format) + }; + + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context) + .setTitle(R.string.choose_andotp_importer) + .setSingleChoiceItems(choices, 0, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + int i = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + Dialogs.showPasswordInputDialog(context, password -> { + try { + decrypt(context, password, i != 0, listener); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }, dialog1 -> listener.onCanceled()); + }) + .create()); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private JSONArray _obj; + + private DecryptedState(JSONArray obj) { + super(false); + _obj = obj; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = new Result(); + + for (int i = 0; i < _obj.length(); i++) { + try { + JSONObject obj = _obj.getJSONObject(i); + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + String type = obj.getString("type").toLowerCase(Locale.ROOT); + String algo = obj.getString("algorithm"); + int digits = obj.getInt("digits"); + byte[] secret = Base32.decode(obj.getString("secret")); + + OtpInfo info; + switch (type) { + case "hotp": + info = new HotpInfo(secret, algo, digits, obj.getLong("counter")); + break; + case "totp": + info = new TotpInfo(secret, algo, digits, obj.getInt("period")); + break; + case "steam": + info = new SteamInfo(secret, algo, digits, obj.optInt("period", TotpInfo.DEFAULT_PERIOD)); + break; + default: + throw new DatabaseImporterException("unsupported otp type: " + type); + } + + String name; + String issuer = ""; + + if (obj.has("issuer")) { + name = obj.getString("label"); + issuer = obj.getString("issuer"); + } else { + String[] parts = obj.getString("label").split(" - "); + if (parts.length > 1) { + issuer = parts[0]; + name = parts[1]; + } else { + name = parts[0]; + } + } + + return new VaultEntry(info, name, issuer); + } catch (DatabaseImporterException | EncodingException | OtpInfoException | + JSONException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorPlusImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorPlusImporter.java new file mode 100644 index 0000000..c171c5d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorPlusImporter.java @@ -0,0 +1,77 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.util.IOUtils; +import com.topjohnwu.superuser.io.SuFile; + +import net.lingala.zip4j.io.inputstream.ZipInputStream; +import net.lingala.zip4j.model.LocalFileHeader; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +public class AuthenticatorPlusImporter extends DatabaseImporter { + private static final String FILENAME = "Accounts.txt"; + + public AuthenticatorPlusImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + return new EncryptedState(IOUtils.readAll(stream)); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class EncryptedState extends DatabaseImporter.State { + private final byte[] _data; + + private EncryptedState(byte[] data) { + super(true); + _data = data; + } + + protected State decrypt(char[] password) throws DatabaseImporterException { + try (ByteArrayInputStream inStream = new ByteArrayInputStream(_data); + ZipInputStream zipStream = new ZipInputStream(inStream, password)) { + LocalFileHeader header; + while ((header = zipStream.getNextEntry()) != null) { + File file = new File(header.getFileName()); + if (file.getName().equals(FILENAME)) { + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(null); + return importer.read(zipStream); + } + } + + throw new FileNotFoundException(FILENAME); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showPasswordInputDialog(context, password -> { + try { + DatabaseImporter.State state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }, dialog1 -> listener.onCanceled()); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java new file mode 100644 index 0000000..890e9b3 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java @@ -0,0 +1,308 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.Xml; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.util.JsonUtils; +import com.beemdevelopment.aegis.util.PreferenceParser; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.Shell; +import com.topjohnwu.superuser.io.SuFile; +import com.topjohnwu.superuser.io.SuFileInputStream; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +public class AuthyImporter extends DatabaseImporter { + private static final String _subPath = "shared_prefs"; + private static final String _pkgName = "com.authy.authy"; + private static final String _authFilename = "com.authy.storage.tokens.authenticator"; + private static final String _authyFilename = "com.authy.storage.tokens.authy"; + + private static final int ITERATIONS = 1000; + private static final int KEY_SIZE = 256; + private static final byte[] IV = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + public AuthyImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + public State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { + SuFile path = getAppPath(); + path.setShell(shell); + + JSONArray array; + JSONArray authyArray; + try { + SuFile file1 = new SuFile(path, String.format("%s.xml", _authFilename)); + file1.setShell(shell); + SuFile file2 = new SuFile(path, String.format("%s.xml", _authyFilename)); + file2.setShell(shell); + + array = readFile(file1, String.format("%s.key", _authFilename)); + authyArray = readFile(file2, String.format("%s.key", _authyFilename)); + } catch (IOException | XmlPullParserException e) { + throw new DatabaseImporterException(e); + } + + try { + for (int i = 0; i < authyArray.length(); i++) { + array.put(authyArray.getJSONObject(i)); + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return read(array); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, null); + parser.nextTag(); + + JSONArray array = new JSONArray(); + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (entry.Name.equals(String.format("%s.key", _authFilename)) + || entry.Name.equals(String.format("%s.key", _authyFilename))) { + array = new JSONArray(entry.Value); + break; + } + } + + return read(array); + } catch (XmlPullParserException | JSONException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + private State read(JSONArray array) throws DatabaseImporterException { + try { + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + if (!obj.has("decryptedSecret") && !obj.has("secretSeed")) { + return new EncryptedState(array); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return new DecryptedState(array); + } + + private JSONArray readFile(SuFile file, String key) throws IOException, XmlPullParserException { + try (InputStream inStream = SuFileInputStream.open(file)) { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(inStream, null); + parser.nextTag(); + + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (entry.Name.equals(key)) { + return new JSONArray(entry.Value); + } + } + } catch (JSONException ignored) { + + } + + return new JSONArray(); + } + + public static class EncryptedState extends DatabaseImporter.State { + private JSONArray _array; + + private EncryptedState(JSONArray array) { + super(true); + _array = array; + } + + protected DecryptedState decrypt(char[] password) throws DatabaseImporterException { + try { + for (int i = 0; i < _array.length(); i++) { + JSONObject obj = _array.getJSONObject(i); + String secretString = JsonUtils.optString(obj, "encryptedSecret"); + if (secretString == null) { + continue; + } + + byte[] encryptedSecret = Base64.decode(secretString); + byte[] salt = obj.getString("salt").getBytes(StandardCharsets.UTF_8); + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + KeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_SIZE); + SecretKey key = factory.generateSecret(spec); + key = new SecretKeySpec(key.getEncoded(), "AES"); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec ivSpec = new IvParameterSpec(IV); + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec); + + byte[] secret = cipher.doFinal(encryptedSecret); + obj.remove("encryptedSecret"); + obj.remove("salt"); + obj.put("decryptedSecret", new String(secret, StandardCharsets.UTF_8)); + } + + return new DecryptedState(_array); + } catch (JSONException + | EncodingException + | NoSuchAlgorithmException + | InvalidKeySpecException + | InvalidAlgorithmParameterException + | InvalidKeyException + | NoSuchPaddingException + | BadPaddingException + | IllegalBlockSizeException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_authy_message, password -> { + try { + DecryptedState state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }, dialog1 -> listener.onCanceled()); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private JSONArray _array; + + private DecryptedState(JSONArray array) { + super(false); + _array = array; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = new Result(); + + try { + for (int i = 0; i < _array.length(); i++) { + JSONObject entryObj = _array.getJSONObject(i); + try { + VaultEntry entry = convertEntry(entryObj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject entry) throws DatabaseImporterEntryException { + try { + AuthyEntryInfo authyEntryInfo = new AuthyEntryInfo(); + authyEntryInfo.OriginalName = JsonUtils.optString(entry, "originalName"); + authyEntryInfo.OriginalIssuer = JsonUtils.optString(entry, "originalIssuer"); + authyEntryInfo.AccountType = JsonUtils.optString(entry, "accountType"); + authyEntryInfo.Name = entry.optString("name"); + + boolean isAuthy = entry.has("secretSeed"); + sanitizeEntryInfo(authyEntryInfo, isAuthy); + + byte[] secret; + if (isAuthy) { + secret = Hex.decode(entry.getString("secretSeed")); + } else { + secret = Base32.decode(entry.getString("decryptedSecret")); + } + + int digits = entry.getInt("digits"); + OtpInfo info = new TotpInfo(secret, OtpInfo.DEFAULT_ALGORITHM, digits, isAuthy ? 10 : TotpInfo.DEFAULT_PERIOD); + return new VaultEntry(info, authyEntryInfo.Name, authyEntryInfo.Issuer); + } catch (OtpInfoException | JSONException | EncodingException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + + private static void sanitizeEntryInfo(AuthyEntryInfo info, boolean isAuthy) { + if (!isAuthy) { + String separator = ""; + + if (info.OriginalIssuer != null) { + info.Issuer = info.OriginalIssuer; + } else if (info.OriginalName != null && info.OriginalName.contains(":")) { + info.Issuer = info.OriginalName.substring(0, info.OriginalName.indexOf(":")); + separator = ":"; + } else if (info.Name.contains(" - ")) { + info.Issuer = info.Name.substring(0, info.Name.indexOf(" - ")); + separator = " - "; + } else { + info.Issuer = info.AccountType.substring(0, 1).toUpperCase() + info.AccountType.substring(1); + } + + info.Name = info.Name.replace(info.Issuer + separator, ""); + } else { + info.Issuer = info.Name; + info.Name = ""; + } + + if (info.Name.startsWith(": ")) { + info.Name = info.Name.substring(2); + } + } + } + + private static class AuthyEntryInfo { + String OriginalName; + String OriginalIssuer; + String AccountType; + String Issuer; + String Name; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java new file mode 100644 index 0000000..221ed0e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java @@ -0,0 +1,124 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.Xml; + +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.util.PreferenceParser; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.common.base.Strings; +import com.topjohnwu.superuser.io.SuFile; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; + +public class BattleNetImporter extends DatabaseImporter { + private static final String _pkgName = "com.blizzard.messenger"; + private static final String _subPath = "shared_prefs/com.blizzard.messenger.authenticator_preferences.xml"; + + private static final byte[] _key; + + public BattleNetImporter(Context context) { + super(context); + } + + static { + try { + _key = Hex.decode("398e27fc50276a656065b0e525f4c06c04c61075286b8e7aeda59da9813b5dd6c80d2fb38068773fa59ba47c17ca6c6479015c1d5b8b8f6b9a"); + } catch (EncodingException e) { + throw new RuntimeException(e); + } + } + + @Override + protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + final String serialKey = "com.blizzard.messenger.AUTHENTICATOR_SERIAL"; + final String secretKey = "com.blizzard.messenger.AUTHENTICATOR_DEVICE_SECRET"; + + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, null); + parser.nextTag(); + + String serial = ""; + String secretValue = null; + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (entry.Name.equals(secretKey)) { + secretValue = entry.Value; + } else if (entry.Name.equals(serialKey)) { + serial = entry.Value; + } + } + + if (secretValue == null) { + throw new DatabaseImporterException(String.format("Key not found: %s", secretKey)); + } + + return new BattleNetImporter.State(serial, secretValue); + } catch (XmlPullParserException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class State extends DatabaseImporter.State { + private final String _serial; + private final String _secretValue; + + public State(String serial, String secretValue) { + super(false); + _serial = serial; + _secretValue = secretValue; + } + + @Override + public Result convert() { + Result result = new Result(); + + try { + VaultEntry entry = convertEntry(_serial, _secretValue); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + + return result; + } + + private static VaultEntry convertEntry(String serial, String secretString) throws DatabaseImporterEntryException { + try { + if (!Strings.isNullOrEmpty(serial)) { + serial = unmask(serial); + } + byte[] secret = Hex.decode(unmask(secretString)); + OtpInfo info = new TotpInfo(secret, OtpInfo.DEFAULT_ALGORITHM, 8, TotpInfo.DEFAULT_PERIOD); + return new VaultEntry(info, serial, "Battle.net"); + } catch (OtpInfoException | EncodingException e) { + throw new DatabaseImporterEntryException(e, secretString); + } + } + + private static String unmask(String s) throws EncodingException { + byte[] ds = Hex.decode(s); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ds.length; i++) { + char c = (char) (ds[i] ^ _key[i]); + sb.append(c); + } + return sb.toString(); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java new file mode 100644 index 0000000..b20ec51 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java @@ -0,0 +1,127 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.net.Uri; + +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.simpleflatmapper.csv.CsvParser; +import org.simpleflatmapper.lightningcsv.Row; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +public class BitwardenImporter extends DatabaseImporter { + public BitwardenImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + String fileString; + try { + fileString = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + try { + JSONObject obj = new JSONObject(fileString); + JSONArray array = obj.getJSONArray("items"); + + List entries = new ArrayList<>(); + String entry; + for (int i = 0; i < array.length(); i++) { + entry = array.getJSONObject(i).getJSONObject("login").getString("totp"); + if (!entry.isEmpty()) { + entries.add(entry); + } + } + + return new BitwardenImporter.State(entries); + } catch (JSONException e) { + try { + Iterator rowIterator = CsvParser.separator(',').rowIterator(fileString); + List entries = new ArrayList<>(); + rowIterator.forEachRemaining((row -> { + String entry = row.get("login_totp"); + if (entry != null && !entry.isEmpty()) { + entries.add(entry); + } + })); + return new BitwardenImporter.State(entries); + } catch (IOException e2) { + throw new DatabaseImporterException(e2); + } + } + } + + public static class State extends DatabaseImporter.State { + private final List _entries; + + public State(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (String obj : _entries) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(String obj) throws DatabaseImporterEntryException { + try { + GoogleAuthInfo info = BitwardenImporter.parseUri(obj); + return new VaultEntry(info); + } catch (GoogleAuthInfoException | EncodingException | OtpInfoException | URISyntaxException e) { + throw new DatabaseImporterEntryException(e, obj); + } + } + } + + private static GoogleAuthInfo parseUri(String s) throws EncodingException, OtpInfoException, URISyntaxException, GoogleAuthInfoException { + Uri uri = Uri.parse(s); + if (Objects.equals(uri.getScheme(), "steam")) { + String secretString = uri.getAuthority(); + if (secretString == null) { + throw new GoogleAuthInfoException(uri, "Empty secret (empty authority)"); + } + byte[] secret = Base32.decode(secretString); + return new GoogleAuthInfo(new SteamInfo(secret), "Steam account", "Steam"); + } + + return GoogleAuthInfo.parseUri(uri); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java new file mode 100644 index 0000000..6c9bc6f --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java @@ -0,0 +1,207 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; + +import androidx.annotation.StringRes; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.util.UUIDMap; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.topjohnwu.superuser.Shell; +import com.topjohnwu.superuser.io.SuFile; +import com.topjohnwu.superuser.io.SuFileInputStream; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public abstract class DatabaseImporter { + private Context _context; + + private static List _importers; + + static { + // note: keep these lists sorted alphabetically + _importers = new ArrayList<>(); + _importers.add(new Definition("2FAS Authenticator", TwoFASImporter.class, R.string.importer_help_2fas, false)); + _importers.add(new Definition("Aegis", AegisImporter.class, R.string.importer_help_aegis, false)); + _importers.add(new Definition("andOTP", AndOtpImporter.class, R.string.importer_help_andotp, false)); + _importers.add(new Definition("Authenticator Plus", AuthenticatorPlusImporter.class, R.string.importer_help_authenticator_plus, false)); + _importers.add(new Definition("Authy", AuthyImporter.class, R.string.importer_help_authy, true)); + _importers.add(new Definition("Battle.net Authenticator", BattleNetImporter.class, R.string.importer_help_battle_net_authenticator, true)); + _importers.add(new Definition("Bitwarden", BitwardenImporter.class, R.string.importer_help_bitwarden, false)); + _importers.add(new Definition("Duo", DuoImporter.class, R.string.importer_help_duo, true)); + _importers.add(new Definition("Ente Auth", EnteAuthImporter.class, R.string.importer_help_ente_auth, false)); + _importers.add(new Definition("FreeOTP", FreeOtpImporter.class, R.string.importer_help_freeotp, true)); + _importers.add(new Definition("FreeOTP+ (JSON)", FreeOtpPlusImporter.class, R.string.importer_help_freeotp_plus, true)); + _importers.add(new Definition("Google Authenticator", GoogleAuthImporter.class, R.string.importer_help_google_authenticator, true)); + _importers.add(new Definition("Microsoft Authenticator", MicrosoftAuthImporter.class, R.string.importer_help_microsoft_authenticator, true)); + _importers.add(new Definition("Plain text", GoogleAuthUriImporter.class, R.string.importer_help_plain_text, false)); + _importers.add(new Definition("Proton Authenticator", ProtonAuthenticatorImporter.class, R.string.importer_help_proton_authenticator, false)); + _importers.add(new Definition("Steam", SteamImporter.class, R.string.importer_help_steam, true)); + _importers.add(new Definition("Stratum (Authenticator Pro)", StratumImporter.class, R.string.importer_help_stratum, true)); + _importers.add(new Definition("TOTP Authenticator", TotpAuthenticatorImporter.class, R.string.importer_help_totp_authenticator, true)); + _importers.add(new Definition("WinAuth", WinAuthImporter.class, R.string.importer_help_winauth, false)); + } + + public DatabaseImporter(Context context) { + _context = context; + } + + protected Context requireContext() { + return _context; + } + + protected abstract SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException; + + protected SuFile getAppPath(String pkgName, String subPath) throws PackageManager.NameNotFoundException { + PackageManager man = requireContext().getPackageManager(); + return new SuFile(man.getApplicationInfo(pkgName, 0).dataDir, subPath); + } + + public boolean isInstalledAppVersionSupported() { + return true; + } + + protected abstract State read(InputStream stream, boolean isInternal) throws DatabaseImporterException; + + public State read(InputStream stream) throws DatabaseImporterException { + return read(stream, false); + } + + public State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { + SuFile file = getAppPath(); + file.setShell(shell); + + try (InputStream stream = SuFileInputStream.open(file)) { + return read(stream, true); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static DatabaseImporter create(Context context, Class type) { + try { + return type.getConstructor(Context.class).newInstance(context); + } catch (IllegalAccessException | InstantiationException + | NoSuchMethodException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + + public static List getImporters(boolean isDirect) { + if (isDirect) { + return Collections.unmodifiableList(_importers.stream().filter(Definition::supportsDirect).collect(Collectors.toList())); + } + + return Collections.unmodifiableList(_importers); + } + + public static class Definition implements Serializable { + private final String _name; + private final Class _type; + private final @StringRes int _help; + private final boolean _supportsDirect; + + /** + * + * @param name The name of the Authenticator the importer handles. + * @param type The class which does the importing. + * @param help The string that explains the type of file needed (and optionally where it can be obtained). + * @param supportsDirect Whether the importer can directly import the entries from the app's internal storage using root access. + */ + public Definition(String name, Class type, @StringRes int help, boolean supportsDirect) { + _name = name; + _type = type; + _help = help; + _supportsDirect = supportsDirect; + } + + public String getName() { + return _name; + } + + public Class getType() { + return _type; + } + + public @StringRes int getHelp() { + return _help; + } + + public boolean supportsDirect() { + return _supportsDirect; + } + } + + public static abstract class State { + private boolean _encrypted; + + public State(boolean encrypted) { + _encrypted = encrypted; + } + + public boolean isEncrypted() { + return _encrypted; + } + + public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException { + if (!_encrypted) { + throw new RuntimeException("Attempted to decrypt a plain text database"); + } + + throw new UnsupportedOperationException(); + } + + public Result convert() throws DatabaseImporterException { + if (_encrypted) { + throw new RuntimeException("Attempted to convert database before decrypting it"); + } + + throw new UnsupportedOperationException(); + } + } + + public static class Result { + private UUIDMap _entries = new UUIDMap<>(); + private UUIDMap _groups = new UUIDMap<>(); + private List _errors = new ArrayList<>(); + + public void addEntry(VaultEntry entry) { + _entries.add(entry); + } + + public void addGroup(VaultGroup group) { + _groups.add(group); + } + + public void addError(DatabaseImporterEntryException error) { + _errors.add(error); + } + + public UUIDMap getEntries() { + return _entries; + } + + public UUIDMap getGroups() { + return _groups; + } + + public List getErrors() { + return _errors; + } + } + + public static abstract class DecryptListener { + protected abstract void onStateDecrypted(State state); + protected abstract void onError(Exception e); + protected abstract void onCanceled(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java new file mode 100644 index 0000000..9df0be8 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java @@ -0,0 +1,19 @@ +package com.beemdevelopment.aegis.importers; + +public class DatabaseImporterEntryException extends Exception { + private String _text; + + public DatabaseImporterEntryException(String message, String text) { + super(message); + _text = text; + } + + public DatabaseImporterEntryException(Throwable cause, String text) { + super(cause); + _text = text; + } + + public String getText() { + return _text; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterException.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterException.java new file mode 100644 index 0000000..694b042 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterException.java @@ -0,0 +1,11 @@ +package com.beemdevelopment.aegis.importers; + +public class DatabaseImporterException extends Exception { + public DatabaseImporterException(Throwable cause) { + super(cause); + } + + public DatabaseImporterException(String message) { + super(message); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java new file mode 100644 index 0000000..687ab43 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java @@ -0,0 +1,99 @@ +package com.beemdevelopment.aegis.importers; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; +import android.content.pm.PackageManager.NameNotFoundException; + +import androidx.annotation.NonNull; + +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; + +public class DuoImporter extends DatabaseImporter { + private static final String _pkgName = "com.duosecurity.duomobile"; + private static final String _subPath = "files/duokit/accounts.json"; + + public DuoImporter(Context context) { + super(context); + } + + @Override + protected @NonNull SuFile getAppPath() throws DatabaseImporterException, NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + protected @NonNull State read( + @NonNull InputStream stream, boolean isInternal + ) throws DatabaseImporterException { + try { + String contents = new String(IOUtils.readAll(stream), UTF_8); + return new DecryptedState(new JSONArray(contents)); + } catch (JSONException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private final JSONArray _array; + + public DecryptedState(@NonNull JSONArray array) { + super(false); + _array = array; + } + + @Override + public @NonNull Result convert() throws DatabaseImporterException { + Result result = new Result(); + + try { + for (int i = 0; i < _array.length(); i++) { + JSONObject entry = _array.getJSONObject(i); + try { + result.addEntry(convertEntry(entry)); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return result; + } + + private static @NonNull VaultEntry convertEntry( + @NonNull JSONObject entry + ) throws DatabaseImporterEntryException { + try { + String label = entry.optString("name"); + JSONObject otpData = entry.getJSONObject("otpGenerator"); + byte[] secret = Base32.decode(otpData.getString("otpSecret")); + Long counter = otpData.has("counter") ? otpData.getLong("counter") : null; + + OtpInfo otp = counter == null + ? new TotpInfo(secret) + : new HotpInfo(secret, counter); + + return new VaultEntry(otp, label, ""); + } catch (JSONException | OtpInfoException | EncodingException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java new file mode 100644 index 0000000..e9196b5 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java @@ -0,0 +1,32 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import com.beemdevelopment.aegis.util.IOUtils; +import com.topjohnwu.superuser.io.SuFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class EnteAuthImporter extends DatabaseImporter { + public EnteAuthImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + byte[] bytes = IOUtils.readAll(stream); + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(requireContext()); + return importer.read(new ByteArrayInputStream(bytes), isInternal); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java new file mode 100644 index 0000000..0266ca9 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java @@ -0,0 +1,471 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.Xml; + +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.ContextHelper; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; +import com.beemdevelopment.aegis.util.PreferenceParser; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.topjohnwu.superuser.io.SuFile; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1Sequence; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class FreeOtpImporter extends DatabaseImporter { + private static final String _subPath = "shared_prefs/tokens.xml"; + private static final String _pkgName = "org.fedorahosted.freeotp"; + + public FreeOtpImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try (BufferedInputStream bufInStream = new BufferedInputStream(stream); + DataInputStream dataInStream = new DataInputStream(bufInStream)) { + + dataInStream.mark(2); + int magic = dataInStream.readUnsignedShort(); + dataInStream.reset(); + + if (magic == SerializedHashMapParser.MAGIC) { + return readV2(dataInStream); + } else { + return readV1(bufInStream); + } + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + } + + private DecryptedStateV1 readV1(InputStream stream) throws DatabaseImporterException { + try { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, null); + parser.nextTag(); + + List entries = new ArrayList<>(); + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (!entry.Name.equals("tokenOrder")) { + entries.add(new JSONObject(entry.Value)); + } + } + return new DecryptedStateV1(entries); + } catch (XmlPullParserException | IOException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + private EncryptedState readV2(DataInputStream stream) throws DatabaseImporterException { + try { + Map entries = SerializedHashMapParser.parse(stream); + JSONObject mkObj = new JSONObject(entries.get("masterKey")); + return new EncryptedState(mkObj, entries); + } catch (IOException | JSONException | SerializedHashMapParser.ParseException e) { + throw new DatabaseImporterException(e); + } + } + + public static class EncryptedState extends State { + private static final int MASTER_KEY_SIZE = 32 * 8; + + private final String _mkAlgo; + private final String _mkCipher; + private final byte[] _mkCipherText; + private final byte[] _mkParameters; + private final byte[] _mkToken; + private final byte[] _mkSalt; + private final int _mkIterations; + private final Map _entries; + + private EncryptedState(JSONObject mkObj, Map entries) + throws DatabaseImporterException, JSONException { + super(true); + + _mkAlgo = mkObj.getString("mAlgorithm"); + if (!_mkAlgo.equals("PBKDF2withHmacSHA1") && !_mkAlgo.equals("PBKDF2withHmacSHA512")) { + throw new DatabaseImporterException(String.format("Unexpected master key KDF: %s", _mkAlgo)); + } + JSONObject keyObj = mkObj.getJSONObject("mEncryptedKey"); + _mkCipher = keyObj.getString("mCipher"); + if (!_mkCipher.equals("AES/GCM/NoPadding")) { + throw new DatabaseImporterException(String.format("Unexpected master key cipher: %s", _mkCipher)); + } + _mkCipherText = toBytes(keyObj.getJSONArray("mCipherText")); + _mkParameters = toBytes(keyObj.getJSONArray("mParameters")); + _mkToken = keyObj.getString("mToken").getBytes(StandardCharsets.UTF_8); + _mkSalt = toBytes(mkObj.getJSONArray("mSalt")); + _mkIterations = mkObj.getInt("mIterations"); + _entries = entries; + } + + public State decrypt(char[] password) throws DatabaseImporterException { + PBKDFTask.Params params = new PBKDFTask.Params(_mkAlgo, MASTER_KEY_SIZE, password, _mkSalt, _mkIterations); + SecretKey passKey = PBKDFTask.deriveKey(params); + return decrypt(passKey); + } + + public State decrypt(SecretKey passKey) throws DatabaseImporterException { + byte[] masterKeyBytes; + try { + byte[] nonce = parseNonce(_mkParameters); + IvParameterSpec spec = new IvParameterSpec(nonce); + Cipher cipher = Cipher.getInstance(_mkCipher); + cipher.init(Cipher.DECRYPT_MODE, passKey, spec); + cipher.updateAAD(_mkToken); + masterKeyBytes = cipher.doFinal(_mkCipherText); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | + IllegalBlockSizeException | InvalidKeyException | + InvalidAlgorithmParameterException | IOException e) { + throw new DatabaseImporterException(e); + } + + SecretKey masterKey = new SecretKeySpec(masterKeyBytes, 0, masterKeyBytes.length, "AES"); + return new DecryptedStateV2(_entries, masterKey); + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.importer_warning_title_freeotp2) + .setMessage(R.string.importer_warning_message_freeotp2) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, password -> { + PBKDFTask.Params params = getKeyDerivationParams(password, _mkAlgo); + PBKDFTask task = new PBKDFTask(context, key -> { + try { + State state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog1 -> listener.onCanceled()); + }) + .create()); + } + + private PBKDFTask.Params getKeyDerivationParams(char[] password, String algo) { + return new PBKDFTask.Params(algo, MASTER_KEY_SIZE, password, _mkSalt, _mkIterations); + } + } + + public static class DecryptedStateV2 extends DatabaseImporter.State { + private final Map _entries; + private final SecretKey _masterKey; + + public DecryptedStateV2(Map entries, SecretKey masterKey) { + super(false); + _entries = entries; + _masterKey = masterKey; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = new Result(); + + for (Map.Entry entry : _entries.entrySet()) { + if (entry.getKey().endsWith("-token") || entry.getKey().equals("masterKey")) { + continue; + } + + try { + JSONObject encObj = new JSONObject(entry.getValue()); + String tokenKey = String.format("%s-token", entry.getKey()); + JSONObject tokenObj = new JSONObject(_entries.get(tokenKey)); + + VaultEntry vaultEntry = convertEntry(encObj, tokenObj); + result.addEntry(vaultEntry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } catch (JSONException ignored) { + } + } + + return result; + } + + private VaultEntry convertEntry(JSONObject encObj, JSONObject tokenObj) + throws DatabaseImporterEntryException { + try { + JSONObject keyObj = new JSONObject(encObj.getString("key")); + String cipherName = keyObj.getString("mCipher"); + if (!cipherName.equals("AES/GCM/NoPadding")) { + throw new DatabaseImporterException(String.format("Unexpected cipher: %s", cipherName)); + } + byte[] cipherText = toBytes(keyObj.getJSONArray("mCipherText")); + byte[] parameters = toBytes(keyObj.getJSONArray("mParameters")); + byte[] token = keyObj.getString("mToken").getBytes(StandardCharsets.UTF_8); + + byte[] nonce = parseNonce(parameters); + IvParameterSpec spec = new IvParameterSpec(nonce); + Cipher cipher = Cipher.getInstance(cipherName); + cipher.init(Cipher.DECRYPT_MODE, _masterKey, spec); + cipher.updateAAD(token); + byte[] secretBytes = cipher.doFinal(cipherText); + + JSONArray secretArray = new JSONArray(); + for (byte b : secretBytes) { + secretArray.put(b); + } + tokenObj.put("secret", secretArray); + + return DecryptedStateV1.convertEntry(tokenObj); + } catch (DatabaseImporterException | JSONException | NoSuchAlgorithmException | + NoSuchPaddingException | InvalidAlgorithmParameterException | + InvalidKeyException | BadPaddingException | IllegalBlockSizeException | + IOException e) { + throw new DatabaseImporterEntryException(e, tokenObj.toString()); + } + } + } + + public static class DecryptedStateV1 extends DatabaseImporter.State { + private final List _entries; + + public DecryptedStateV1(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (JSONObject obj : _entries) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + String type = obj.getString("type").toLowerCase(Locale.ROOT); + String algo = obj.optString("algo", OtpInfo.DEFAULT_ALGORITHM); + int digits = obj.optInt("digits", OtpInfo.DEFAULT_DIGITS); + byte[] secret = toBytes(obj.getJSONArray("secret")); + + String issuer = obj.getString("issuerExt"); + String name = obj.optString("label"); + + OtpInfo info; + switch (type) { + case "totp": + int period = obj.optInt("period", TotpInfo.DEFAULT_PERIOD); + if (issuer.equals("Steam")) { + info = new SteamInfo(secret, algo, digits, period); + } else { + info = new TotpInfo(secret, algo, digits, period); + } + break; + case "hotp": + info = new HotpInfo(secret, algo, digits, obj.getLong("counter")); + break; + default: + throw new DatabaseImporterException("unsupported otp type: " + type); + } + + return new VaultEntry(info, name, issuer); + } catch (DatabaseImporterException | OtpInfoException | JSONException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } + + private static byte[] parseNonce(byte[] parameters) throws IOException { + ASN1Primitive prim = ASN1Sequence.fromByteArray(parameters); + if (prim instanceof ASN1OctetString) { + return ((ASN1OctetString) prim).getOctets(); + } + + if (prim instanceof ASN1Sequence) { + for (ASN1Encodable enc : (ASN1Sequence) prim) { + if (enc instanceof ASN1OctetString) { + return ((ASN1OctetString) enc).getOctets(); + } + } + } + + throw new IOException("Unable to find nonce in parameters"); + } + + private static byte[] toBytes(JSONArray array) throws JSONException { + byte[] bytes = new byte[array.length()]; + for (int i = 0; i < array.length(); i++) { + bytes[i] = (byte)array.getInt(i); + } + return bytes; + } + private static class SerializedHashMapParser { + private static final int MAGIC = 0xaced; + private static final int VERSION = 5; + private static final long SERIAL_VERSION_UID = 362498820763181265L; + + private static final byte TC_NULL = 0x70; + private static final byte TC_CLASSDESC = 0x72; + private static final byte TC_OBJECT = 0x73; + private static final byte TC_STRING = 0x74; + + private SerializedHashMapParser() { + + } + + public static Map parse(DataInputStream inStream) + throws IOException, ParseException { + Map map = new HashMap<>(); + + // Read/validate the magic number and version + int magic = inStream.readUnsignedShort(); + int version = inStream.readUnsignedShort(); + if (magic != MAGIC || version != VERSION) { + throw new ParseException("Not a serialized Java Object"); + } + + // Read the class descriptor info for HashMap + byte b = inStream.readByte(); + if (b != TC_OBJECT) { + throw new ParseException("Expected an object, found: " + b); + } + b = inStream.readByte(); + if (b != TC_CLASSDESC) { + throw new ParseException("Expected a class desc, found: " + b); + } + parseClassDescriptor(inStream); + + // Not interested in the capacity of the map + inStream.readInt(); + // Read the number of elements in the HashMap + int size = inStream.readInt(); + + // Parse each key-value pair in the map + for (int i = 0; i < size; i++) { + String key = parseStringObject(inStream); + String value = parseStringObject(inStream); + map.put(key, value); + } + + return map; + } + + private static void parseClassDescriptor(DataInputStream inputStream) + throws IOException, ParseException { + // Check whether we're dealing with a HashMap and a version we support + String className = parseUTF(inputStream); + if (!className.equals(HashMap.class.getName())) { + throw new ParseException(String.format("Unexpected class name: %s", className)); + } + long serialVersionUID = inputStream.readLong(); + if (serialVersionUID != SERIAL_VERSION_UID) { + throw new ParseException(String.format("Unexpected serial version UID: %d", serialVersionUID)); + } + + // Read past all of the fields in the class + byte fieldDescriptor = inputStream.readByte(); + if (fieldDescriptor == TC_NULL) { + return; + } + int totalFieldSkip = 0; + int fieldCount = inputStream.readUnsignedShort(); + for (int i = 0; i < fieldCount; i++) { + char fieldType = (char) inputStream.readByte(); + parseUTF(inputStream); + switch (fieldType) { + case 'F': // float (4 bytes) + case 'I': // int (4 bytes) + totalFieldSkip += 4; + break; + default: + throw new ParseException(String.format("Unexpected field type: %s", fieldType)); + } + } + inputStream.skipBytes(totalFieldSkip); + + // Not sure what these bytes are, just skip them + inputStream.skipBytes(4); + } + + private static String parseStringObject(DataInputStream inputStream) + throws IOException, ParseException { + byte objectType = inputStream.readByte(); + if (objectType != TC_STRING) { + throw new ParseException(String.format("Expected a string object, found: %d", objectType)); + } + + int length = inputStream.readUnsignedShort(); + byte[] strBytes = new byte[length]; + inputStream.readFully(strBytes); + + return new String(strBytes, StandardCharsets.UTF_8); + } + + private static String parseUTF(DataInputStream inputStream) throws IOException { + int length = inputStream.readUnsignedShort(); + byte[] strBytes = new byte[length]; + inputStream.readFully(strBytes); + return new String(strBytes, StandardCharsets.UTF_8); + } + + private static class ParseException extends Exception { + public ParseException(String message) { + super(message); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java new file mode 100644 index 0000000..02cbd19 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java @@ -0,0 +1,57 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; + +import com.beemdevelopment.aegis.util.IOUtils; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class FreeOtpPlusImporter extends DatabaseImporter { + private static final String _subPath = "shared_prefs/tokens.xml"; + private static final String _pkgName = "org.liberty.android.freeotpplus"; + + public FreeOtpPlusImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + State state; + + if (isInternal) { + state = new FreeOtpImporter(requireContext()).read(stream); + } else { + try { + String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); + JSONObject obj = new JSONObject(json); + JSONArray array = obj.getJSONArray("tokens"); + + List entries = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + entries.add(array.getJSONObject(i)); + } + + state = new FreeOtpImporter.DecryptedStateV1(entries); + } catch (IOException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + return state; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java new file mode 100644 index 0000000..267a73a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java @@ -0,0 +1,171 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.database.Cursor; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.Shell; +import com.topjohnwu.superuser.io.SuFile; + +import java.io.InputStream; +import java.util.List; + +public class GoogleAuthImporter extends DatabaseImporter { + private static final int TYPE_TOTP = 0; + private static final int TYPE_HOTP = 1; + + private static final String _subPath = "databases/databases"; + private static final String _pkgName = "com.google.android.apps.authenticator2"; + + public GoogleAuthImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws PackageManager.NameNotFoundException { + SuFile file = getAppPath(_pkgName, _subPath); + return file; + } + + @Override + public boolean isInstalledAppVersionSupported() { + PackageInfo info; + try { + info = requireContext().getPackageManager().getPackageInfo(_pkgName, 0); + } catch (PackageManager.NameNotFoundException e) { + return false; + } + + return info.versionCode <= 5000100; + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + final Context context = requireContext(); + SqlImporterHelper helper = new SqlImporterHelper(context); + List entries = helper.read(Entry.class, stream, "accounts"); + return new State(entries, context); + } + + @Override + public DatabaseImporter.State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { + SuFile path = getAppPath(); + path.setShell(shell); + + final Context context = requireContext(); + SqlImporterHelper helper = new SqlImporterHelper(context); + List entries = helper.read(Entry.class, path, "accounts"); + return new State(entries, context); + } + + public static class State extends DatabaseImporter.State { + private List _entries; + private Context _context; + + private State(List entries, Context context) { + super(false); + _entries = entries; + _context = context; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (Entry sqlEntry : _entries) { + try { + VaultEntry entry = convertEntry(sqlEntry, _context); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(Entry entry, Context context) throws DatabaseImporterEntryException { + try { + if (entry.isEncrypted()) { + throw new DatabaseImporterException(context.getString(R.string.importer_encrypted_exception_google_authenticator, entry.getEmail())); + } + byte[] secret = GoogleAuthInfo.parseSecret(entry.getSecret()); + + OtpInfo info; + switch (entry.getType()) { + case TYPE_TOTP: + info = new TotpInfo(secret); + break; + case TYPE_HOTP: + info = new HotpInfo(secret, entry.getCounter()); + break; + default: + throw new DatabaseImporterException("unsupported otp type: " + entry.getType()); + } + + String name = entry.getEmail(); + String[] parts = name.split(":"); + if (parts.length == 2) { + name = parts[1]; + } + + return new VaultEntry(info, name, entry.getIssuer()); + } catch (EncodingException | OtpInfoException | DatabaseImporterException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + } + + private static class Entry extends SqlImporterHelper.Entry { + private int _type; + private boolean _isEncrypted; + private String _secret; + private String _email; + private String _issuer; + private long _counter; + + public Entry(Cursor cursor) { + super(cursor); + _type = SqlImporterHelper.getInt(cursor, "type"); + _secret = SqlImporterHelper.getString(cursor, "secret"); + _email = SqlImporterHelper.getString(cursor, "email", ""); + _issuer = SqlImporterHelper.getString(cursor, "issuer", ""); + _counter = SqlImporterHelper.getLong(cursor, "counter"); + _isEncrypted = (cursor.getColumnIndex("isencrypted") != -1 && SqlImporterHelper.getInt(cursor, "isencrypted") > 0); + } + + + public int getType() { + return _type; + } + + public boolean isEncrypted() { + return _isEncrypted; + } + + public String getSecret() { + return _secret; + } + + public String getEmail() { + return _email; + } + + public String getIssuer() { + return _issuer; + } + + public long getCounter() { + return _counter; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthUriImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthUriImporter.java new file mode 100644 index 0000000..7923a16 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthUriImporter.java @@ -0,0 +1,78 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; + +public class GoogleAuthUriImporter extends DatabaseImporter { + public GoogleAuthUriImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + public GoogleAuthUriImporter.State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + ArrayList lines = new ArrayList<>(); + + try (InputStreamReader streamReader = new InputStreamReader(stream); + BufferedReader bufferedReader = new BufferedReader(streamReader)) { + String line; + while ((line = bufferedReader.readLine()) != null) { + if (!line.isEmpty()) { + lines.add(line); + } + } + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + + return new GoogleAuthUriImporter.State(lines); + } + + public static class State extends DatabaseImporter.State { + private ArrayList _lines; + + private State(ArrayList lines) { + super(false); + _lines = lines; + } + + @Override + public DatabaseImporter.Result convert() { + DatabaseImporter.Result result = new DatabaseImporter.Result(); + + for (String line : _lines) { + try { + VaultEntry entry = convertEntry(line); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(String line) throws DatabaseImporterEntryException { + try { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(line); + return new VaultEntry(info); + } catch (GoogleAuthInfoException e) { + throw new DatabaseImporterEntryException(e, line); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java new file mode 100644 index 0000000..4e84284 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java @@ -0,0 +1,135 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; + +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.Shell; +import com.topjohnwu.superuser.io.SuFile; + +import java.io.InputStream; +import java.util.List; + +public class MicrosoftAuthImporter extends DatabaseImporter { + private static final String _subPath = "databases/PhoneFactor"; + private static final String _pkgName = "com.azure.authenticator"; + + private static final int TYPE_TOTP = 0; + private static final int TYPE_MICROSOFT = 1; + + public MicrosoftAuthImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + SqlImporterHelper helper = new SqlImporterHelper(requireContext()); + List entries = helper.read(Entry.class, stream, "accounts"); + return new State(entries); + } + + @Override + public DatabaseImporter.State readFromApp(Shell shell) throws PackageManager.NameNotFoundException, DatabaseImporterException { + SuFile path = getAppPath(); + path.setShell(shell); + + SqlImporterHelper helper = new SqlImporterHelper(requireContext()); + List entries = helper.read(Entry.class, path, "accounts"); + return new State(entries); + } + + public static class State extends DatabaseImporter.State { + private List _entries; + + private State(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (Entry sqlEntry : _entries) { + try { + int type = sqlEntry.getType(); + if (type == TYPE_TOTP || type == TYPE_MICROSOFT) { + VaultEntry entry = convertEntry(sqlEntry); + result.addEntry(entry); + } + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(Entry entry) throws DatabaseImporterEntryException { + try { + byte[] secret; + int digits = 6; + + switch (entry.getType()) { + case TYPE_TOTP: + secret = GoogleAuthInfo.parseSecret(entry.getSecret()); + break; + case TYPE_MICROSOFT: + digits = 8; + secret = Base64.decode(entry.getSecret()); + break; + default: + throw new DatabaseImporterEntryException(String.format("Unsupported OTP type: %d", entry.getType()), entry.toString()); + } + + OtpInfo info = new TotpInfo(secret, OtpInfo.DEFAULT_ALGORITHM, digits, TotpInfo.DEFAULT_PERIOD); + return new VaultEntry(info, entry.getUserName(), entry.getIssuer()); + } catch (EncodingException | OtpInfoException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + } + + private static class Entry extends SqlImporterHelper.Entry { + private int _type; + private String _secret; + private String _issuer; + private String _userName; + + public Entry(Cursor cursor) { + super(cursor); + _type = SqlImporterHelper.getInt(cursor, "account_type"); + _secret = SqlImporterHelper.getString(cursor, "oath_secret_key"); + _issuer = SqlImporterHelper.getString(cursor, "name"); + _userName = SqlImporterHelper.getString(cursor, "username"); + } + + public int getType() { + return _type; + } + + public String getSecret() { + return _secret; + } + + public String getIssuer() { + return _issuer; + } + + public String getUserName() { + return _userName; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java new file mode 100644 index 0000000..2ac34db --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java @@ -0,0 +1,96 @@ +package com.beemdevelopment.aegis.importers; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; + +public class ProtonAuthenticatorImporter extends DatabaseImporter { + + public ProtonAuthenticatorImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + protected @NonNull State read(@NonNull InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + String contents = new String(IOUtils.readAll(stream), UTF_8); + JSONObject json = new JSONObject(contents); + + return new DecryptedState(json); + } catch (JSONException | IOException e) { + throw new DatabaseImporterException(e); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private final JSONObject _json; + + public DecryptedState(@NonNull JSONObject json) { + super(false); + _json = json; + } + + @Override + public @NonNull Result convert() throws DatabaseImporterException { + Result result = new Result(); + + try { + JSONArray entries = _json.getJSONArray("entries"); + for (int i = 0; i < entries.length(); i++) { + JSONObject entry = entries.getJSONObject(i); + try { + result.addEntry(convertEntry(entry)); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return result; + } + + private static @NonNull VaultEntry convertEntry(@NonNull JSONObject entry) throws DatabaseImporterEntryException { + try { + JSONObject content = entry.getJSONObject("content"); + String name = content.getString("name"); + String uriString = content.getString("uri"); + + Uri uri = Uri.parse(uriString); + try { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri); + OtpInfo otp = info.getOtpInfo(); + + return new VaultEntry(otp, name, info.getIssuer()); + } catch (GoogleAuthInfoException e) { + throw new DatabaseImporterEntryException(e, uriString); + } + } catch (JSONException e) { + throw new DatabaseImporterEntryException(e, entry.toString()); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java b/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java new file mode 100644 index 0000000..f1403d9 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java @@ -0,0 +1,150 @@ +package com.beemdevelopment.aegis.importers; + +import static android.database.sqlite.SQLiteDatabase.OPEN_READONLY; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +import com.beemdevelopment.aegis.util.IOUtils; +import com.google.common.io.Files; +import com.topjohnwu.superuser.io.SuFile; +import com.topjohnwu.superuser.io.SuFileInputStream; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; + +public class SqlImporterHelper { + private Context _context; + + public SqlImporterHelper(Context context) { + _context = context; + } + + public List read(Class type, SuFile path, String table) throws DatabaseImporterException { + File dir = Files.createTempDir(); + File mainFile = new File(dir, path.getName()); + + List fileCopies = new ArrayList<>(); + for (SuFile file : SqlImporterHelper.findDatabaseFiles(path)) { + // create temporary copies of the database files so that SQLiteDatabase can open them + File fileCopy = null; + try (InputStream inStream = SuFileInputStream.open(file)) { + fileCopy = new File(dir, file.getName()); + try (FileOutputStream out = new FileOutputStream(fileCopy)) { + IOUtils.copy(inStream, out); + } + fileCopies.add(fileCopy); + } catch (IOException e) { + if (fileCopy != null) { + fileCopy.delete(); + } + + for (File fileCopy2 : fileCopies) { + fileCopy2.delete(); + } + + throw new DatabaseImporterException(e); + } + } + + try { + return read(type, mainFile, table); + } finally { + for (File fileCopy : fileCopies) { + fileCopy.delete(); + } + } + } + + private static SuFile[] findDatabaseFiles(SuFile path) throws DatabaseImporterException { + SuFile[] files = path.getParentFile().listFiles((d, name) -> name.startsWith(path.getName())); + if (files == null || files.length == 0) { + throw new DatabaseImporterException(String.format("File does not exist: %s", path.getAbsolutePath())); + } + + return files; + } + + public List read(Class type, InputStream inStream, String table) throws DatabaseImporterException { + File file = null; + try { + // create a temporary copy of the database so that SQLiteDatabase can open it + file = File.createTempFile("db-import-", "", _context.getCacheDir()); + try (FileOutputStream out = new FileOutputStream(file)) { + IOUtils.copy(inStream, out); + } + } catch (IOException e) { + if (file != null) { + file.delete(); + } + throw new DatabaseImporterException(e); + } + + try { + return read(type, file, table); + } finally { + // always delete the temporary file + file.delete(); + } + } + + private List read(Class type, File file, String table) throws DatabaseImporterException { + try (SQLiteDatabase db = SQLiteDatabase.openDatabase(file.getAbsolutePath(), null, OPEN_READONLY)) { + try (Cursor cursor = db.rawQuery(String.format("SELECT * FROM %s", table), null)) { + List entries = new ArrayList<>(); + + if (cursor.moveToFirst()) { + do { + T entry = type.getDeclaredConstructor(Cursor.class).newInstance(cursor); + entries.add(entry); + } while (cursor.moveToNext()); + } + + return entries; + } catch (InstantiationException | IllegalAccessException + | NoSuchMethodException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } catch (SQLiteException e) { + throw new DatabaseImporterException(e); + } + } + + @SuppressLint("Range") + public static String getString(Cursor cursor, String columnName) { + return cursor.getString(cursor.getColumnIndex(columnName)); + } + + @SuppressLint("Range") + public static String getString(Cursor cursor, String columnName, String def) { + String res = cursor.getString(cursor.getColumnIndex(columnName)); + if (res == null) { + return def; + } + return res; + } + + @SuppressLint("Range") + public static int getInt(Cursor cursor, String columnName) { + return cursor.getInt(cursor.getColumnIndex(columnName)); + } + + @SuppressLint("Range") + public static long getLong(Cursor cursor, String columnName) { + return cursor.getLong(cursor.getColumnIndex(columnName)); + } + + public static abstract class Entry { + public Entry(Cursor cursor) { + + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java new file mode 100644 index 0000000..bdb550e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java @@ -0,0 +1,118 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Array; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +public class SteamImporter extends DatabaseImporter { + private static final String _subDir = "files"; + private static final String _pkgName = "com.valvesoftware.android.steam.community"; + + public SteamImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException { + // NOTE: this assumes that a global root shell has already been obtained by the caller + SuFile path = getAppPath(_pkgName, _subDir); + SuFile[] files = path.listFiles((d, name) -> name.startsWith("Steamguard-")); + if (files == null || files.length == 0) { + throw new DatabaseImporterException(String.format("Empty directory: %s", path.getAbsolutePath())); + } + + // TODO: handle multiple files (can this even occur?) + return files[0]; + } + + @Override + public boolean isInstalledAppVersionSupported() { + PackageInfo info; + try { + info = requireContext().getPackageManager().getPackageInfo(_pkgName, 0); + } catch (PackageManager.NameNotFoundException e) { + return false; + } + + return info.versionCode < 7460894; + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + byte[] bytes = IOUtils.readAll(stream); + JSONObject obj = new JSONObject(new String(bytes, StandardCharsets.UTF_8)); + + List objs = new ArrayList<>(); + if (obj.has("accounts")) { + JSONObject accounts = obj.getJSONObject("accounts"); + Iterator keys = accounts.keys(); + while (keys.hasNext()) { + String key = keys.next(); + objs.add(accounts.getJSONObject(key)); + } + } else { + objs.add(obj); + } + return new State(objs); + } catch (IOException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + public static class State extends DatabaseImporter.State { + private final List _objs; + + private State(List objs) { + super(false); + _objs = objs; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (JSONObject obj : _objs) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + byte[] secret = Base64.decode(obj.getString("shared_secret")); + SteamInfo info = new SteamInfo(secret); + + String account = obj.getString("account_name"); + return new VaultEntry(info, account, "Steam"); + } catch (JSONException | EncodingException | OtpInfoException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java new file mode 100644 index 0000000..7504d98 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java @@ -0,0 +1,380 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; + +import androidx.lifecycle.Lifecycle; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.helpers.ContextHelper; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.Argon2Task; +import com.beemdevelopment.aegis.ui.tasks.PBKDFTask; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UTFDataFormatException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; + +public class StratumImporter extends DatabaseImporter { + private static final String HEADER = "AUTHENTICATORPRO"; + private static final String HEADER_LEGACY = "AuthenticatorPro"; + private static final String PKG_NAME = "com.stratumauth.app"; + private static final String PKG_DB_PATH = "databases/authenticator.db3"; + + private enum Algorithm { + SHA1, + SHA256, + SHA512 + } + + public StratumImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws DatabaseImporterException, PackageManager.NameNotFoundException { + return getAppPath(PKG_NAME, PKG_DB_PATH); + } + + @Override + protected State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + return isInternal ? readInternal(stream) : readExternal(stream); + } + + private State readInternal(InputStream stream) throws DatabaseImporterException { + List entries = new SqlImporterHelper(requireContext()).read(SqlEntry.class, stream, "authenticator"); + return new SqlState(entries); + } + + private static State readExternal(InputStream stream) throws DatabaseImporterException { + byte[] data; + try { + data = IOUtils.readAll(stream); + } catch (IOException e) { + throw new DatabaseImporterException(e); + } + + try { + return new JsonState(new JSONObject(new String(data, StandardCharsets.UTF_8))); + } catch (JSONException e) { + return readEncrypted(new DataInputStream(new ByteArrayInputStream(data))); + } + } + + private static State readEncrypted(DataInputStream stream) throws DatabaseImporterException { + try { + byte[] headerBytes = new byte[HEADER.getBytes(StandardCharsets.UTF_8).length]; + stream.readFully(headerBytes); + String header = new String(headerBytes, StandardCharsets.UTF_8); + switch (header) { + case HEADER: + return EncryptedState.parseHeader(stream); + case HEADER_LEGACY: + return LegacyEncryptedState.parseHeader(stream); + default: + throw new DatabaseImporterException("Invalid file header"); + } + } catch (UTFDataFormatException e) { + throw new DatabaseImporterException("Invalid file header"); + } catch (IOException | NoSuchPaddingException | NoSuchAlgorithmException e) { + throw new DatabaseImporterException(e); + } + } + + private static OtpInfo parseOtpInfo(int type, byte[] secret, Algorithm algo, int digits, int period, int counter) + throws OtpInfoException, DatabaseImporterEntryException { + switch (type) { + case 1: + return new HotpInfo(secret, algo.name(), digits, counter); + case 2: + return new TotpInfo(secret, algo.name(), digits, period); + case 4: + return new SteamInfo(secret, algo.name(), digits, period); + default: + throw new DatabaseImporterEntryException(String.format("Unsupported otp type: %d", type), null); + } + } + + static class EncryptedState extends State { + private static final int KEY_SIZE = 32; + private static final int MEMORY_COST = 16; // 2^16 KiB = 64 MiB + private static final int PARALLELISM = 4; + private static final int ITERATIONS = 3; + private static final int SALT_SIZE = 16; + private static final int IV_SIZE = 12; + + private final Cipher _cipher; + private final byte[] _salt; + private final byte[] _iv; + private final byte[] _data; + + public EncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) { + super(true); + _cipher = cipher; + _salt = salt; + _iv = iv; + _data = data; + } + + public JsonState decrypt(char[] password) throws DatabaseImporterException { + Argon2Task.Params params = getKeyDerivationParams(password); + SecretKey key = Argon2Task.deriveKey(params); + return decrypt(key); + } + + public JsonState decrypt(SecretKey key) throws DatabaseImporterException { + try { + _cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(_iv)); + byte[] decrypted = _cipher.doFinal(_data); + return new JsonState(new JSONObject(new String(decrypted, StandardCharsets.UTF_8))); + } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException + | JSONException | InvalidKeyException | BadPaddingException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> { + Argon2Task.Params params = getKeyDerivationParams(password); + Argon2Task task = new Argon2Task(context, key -> { + try { + StratumImporter.JsonState state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog -> listener.onCanceled()); + } + + private Argon2Task.Params getKeyDerivationParams(char[] password) { + Argon2Parameters argon2Params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withIterations(ITERATIONS) + .withParallelism(PARALLELISM) + .withMemoryPowOfTwo(MEMORY_COST) + .withSalt(_salt) + .build(); + return new Argon2Task.Params(password, argon2Params, KEY_SIZE); + } + + private static EncryptedState parseHeader(DataInputStream stream) + throws IOException, NoSuchPaddingException, NoSuchAlgorithmException { + byte[] salt = new byte[SALT_SIZE]; + stream.readFully(salt); + + byte[] iv = new byte[IV_SIZE]; + stream.readFully(iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + return new EncryptedState(cipher, salt, iv, IOUtils.readAll(stream)); + } + } + + static class LegacyEncryptedState extends State { + private static final int ITERATIONS = 64000; + private static final int KEY_SIZE = 32 * Byte.SIZE; + private static final int SALT_SIZE = 20; + + private final Cipher _cipher; + private final byte[] _salt; + private final byte[] _iv; + private final byte[] _data; + + public LegacyEncryptedState(Cipher cipher, byte[] salt, byte[] iv, byte[] data) { + super(true); + _cipher = cipher; + _salt = salt; + _iv = iv; + _data = data; + } + + public JsonState decrypt(char[] password) throws DatabaseImporterException { + PBKDFTask.Params params = getKeyDerivationParams(password); + SecretKey key = PBKDFTask.deriveKey(params); + return decrypt(key); + } + + public JsonState decrypt(SecretKey key) throws DatabaseImporterException { + try { + _cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(_iv)); + byte[] decrypted = _cipher.doFinal(_data); + return new JsonState(new JSONObject(new String(decrypted, StandardCharsets.UTF_8))); + } catch (InvalidAlgorithmParameterException | IllegalBlockSizeException + | JSONException | InvalidKeyException | BadPaddingException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) throws DatabaseImporterException { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_aegis_title, 0, (Dialogs.TextInputListener) password -> { + PBKDFTask.Params params = getKeyDerivationParams(password); + PBKDFTask task = new PBKDFTask(context, key -> { + try { + StratumImporter.JsonState state = decrypt(key); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }); + Lifecycle lifecycle = ContextHelper.getLifecycle(context); + task.execute(lifecycle, params); + }, dialog -> listener.onCanceled()); + } + + private PBKDFTask.Params getKeyDerivationParams(char[] password) { + return new PBKDFTask.Params("PBKDF2WithHmacSHA1", KEY_SIZE, password, _salt, ITERATIONS); + } + + private static LegacyEncryptedState parseHeader(DataInputStream stream) + throws IOException, NoSuchPaddingException, NoSuchAlgorithmException { + byte[] salt = new byte[SALT_SIZE]; + stream.readFully(salt); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + int ivSize = cipher.getBlockSize(); + byte[] iv = new byte[ivSize]; + stream.readFully(iv); + return new LegacyEncryptedState(cipher, salt, iv, IOUtils.readAll(stream)); + } + } + + private static class JsonState extends State { + private final JSONObject _obj; + + public JsonState(JSONObject obj) { + super(false); + _obj = obj; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result res = new Result(); + + try { + JSONArray array = _obj.getJSONArray("Authenticators"); + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + try { + res.addEntry(convertEntry(obj)); + } catch (DatabaseImporterEntryException e) { + res.addError(e); + } + } + } catch (JSONException e) { + throw new DatabaseImporterException(e); + } + + return res; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + int type = obj.getInt("Type"); + String issuer = obj.getString("Issuer"); + Object nullableUsername = obj.get("Username"); + String username = nullableUsername == JSONObject.NULL ? "" : nullableUsername.toString(); + byte[] secret = Base32.decode(obj.getString("Secret")); + Algorithm algo = Algorithm.values()[obj.getInt("Algorithm")]; + int digits = obj.getInt("Digits"); + int period = obj.getInt("Period"); + int counter = obj.getInt("Counter"); + + OtpInfo info = parseOtpInfo(type, secret, algo, digits, period, counter); + return new VaultEntry(info, username, issuer); + } catch (OtpInfoException | EncodingException | JSONException e) { + throw new DatabaseImporterEntryException(e, null); + } + } + } + + private static class SqlState extends State { + private final List _entries; + + public SqlState(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result res = new Result(); + + for (SqlEntry entry : _entries) { + try { + res.addEntry(entry.convert()); + } catch (DatabaseImporterEntryException e) { + res.addError(e); + } + } + + return res; + } + } + + private static class SqlEntry extends SqlImporterHelper.Entry { + private final int _type; + private final String _issuer; + private final String _username; + private final String _secret; + private final Algorithm _algo; + private final int _digits; + private final int _period; + private final int _counter; + + public SqlEntry(Cursor cursor) { + super(cursor); + _type = SqlImporterHelper.getInt(cursor, "type"); + _issuer = SqlImporterHelper.getString(cursor, "issuer"); + _username = SqlImporterHelper.getString(cursor, "username"); + _secret = SqlImporterHelper.getString(cursor, "secret"); + _algo = Algorithm.values()[SqlImporterHelper.getInt(cursor, "algorithm")]; + _digits = SqlImporterHelper.getInt(cursor, "digits"); + _period = SqlImporterHelper.getInt(cursor, "period"); + _counter = SqlImporterHelper.getInt(cursor, "counter"); + } + + public VaultEntry convert() throws DatabaseImporterEntryException { + try { + byte[] secret = Base32.decode(_secret); + OtpInfo info = parseOtpInfo(_type, secret, _algo, _digits, _period, _counter); + return new VaultEntry(info, _username, _issuer); + } catch (EncodingException | OtpInfoException e) { + throw new DatabaseImporterEntryException(e, null); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java new file mode 100644 index 0000000..699f39b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java @@ -0,0 +1,233 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.util.Xml; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.util.PreferenceParser; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class TotpAuthenticatorImporter extends DatabaseImporter { + private static final String _subPath = "shared_prefs/TOTP_Authenticator_Preferences.xml"; + private static final String _pkgName = "com.authenticator.authservice2"; + + // WARNING: DON'T DO THIS IN YOUR OWN CODE + // this is a hardcoded password and nonce, used solely to decrypt TOTP Authenticator backups + private static final char[] PASSWORD = "TotpAuthenticator".toCharArray(); + private static final byte[] IV = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + private static final String PREF_KEY = "STATIC_TOTP_CODES_LIST"; + + public TotpAuthenticatorImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() throws PackageManager.NameNotFoundException { + return getAppPath(_pkgName, _subPath); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + if (isInternal) { + XmlPullParser parser = Xml.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false); + parser.setInput(stream, null); + parser.nextTag(); + + String data = null; + for (PreferenceParser.XmlEntry entry : PreferenceParser.parse(parser)) { + if (entry.Name.equals(PREF_KEY)) { + data = entry.Value; + } + } + + if (data == null) { + throw new DatabaseImporterException(String.format("Key %s not found in shared preference file", PREF_KEY)); + } + + List entries = parse(data); + return new DecryptedState(entries); + } else { + byte[] base64 = IOUtils.readAll(stream); + byte[] cipherText = Base64.decode(base64); + return new EncryptedState(cipherText); + } + } catch (IOException | XmlPullParserException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + private static List parse(String data) throws JSONException { + JSONArray array = new JSONArray(data); + + List entries = new ArrayList<>(); + for (int i = 0; i < array.length(); ++i) { + JSONObject obj = array.getJSONObject(i); + entries.add(obj); + } + + return entries; + } + + public static class EncryptedState extends DatabaseImporter.State { + private byte[] _data; + + public EncryptedState(byte[] data) { + super(true); + _data = data; + } + + protected DecryptedState decrypt(char[] password) throws DatabaseImporterException { + try { + // WARNING: DON'T DO THIS IN YOUR OWN CODE + // this is not a secure way to derive a key from a password + MessageDigest hash = MessageDigest.getInstance("SHA-256"); + byte[] keyBytes = hash.digest(CryptoUtils.toBytes(password)); + SecretKey key = new SecretKeySpec(keyBytes, "AES"); + + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + IvParameterSpec spec = new IvParameterSpec(IV); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + byte[] bytes = cipher.doFinal(_data); + JSONObject obj = new JSONObject(new String(bytes, StandardCharsets.UTF_8)); + JSONArray keys = obj.names(); + + List entries = new ArrayList<>(); + if (keys != null && keys.length() > 0) { + entries = parse((String) keys.get(0)); + } + + return new DecryptedState(entries); + } catch (NoSuchAlgorithmException + | NoSuchPaddingException + | InvalidAlgorithmParameterException + | InvalidKeyException + | BadPaddingException + | IllegalBlockSizeException + | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(context) + .setMessage(R.string.choose_totpauth_importer) + .setPositiveButton(R.string.yes, (dialog, which) -> { + Dialogs.showPasswordInputDialog(context, password -> { + decrypt(password, listener); + }, dialog1 -> listener.onCanceled()); + }) + .setNegativeButton(R.string.no, (dialog, which) -> { + decrypt(PASSWORD, listener); + }) + .create()); + } + + private void decrypt(char[] password, DecryptListener listener) { + try { + DecryptedState state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private List _objs; + + private DecryptedState(List objs) { + super(false); + _objs = objs; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (JSONObject obj : _objs) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + int base = obj.getInt("base"); + String secretString = obj.getString("key"); + + byte[] secret; + switch (base) { + case 16: + secret = Hex.decode(secretString); + break; + case 32: + secret = Base32.decode(secretString); + break; + case 64: + secret = Base64.decode(secretString); + break; + default: + throw new DatabaseImporterEntryException(String.format("Unsupported secret encoding: base %d", base), obj.toString()); + } + + TotpInfo info = new TotpInfo(secret); + String name = obj.optString("name"); + String issuer = obj.optString("issuer"); + + return new VaultEntry(info, name, issuer); + } catch (JSONException | OtpInfoException | EncodingException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java new file mode 100644 index 0000000..f0387af --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java @@ -0,0 +1,208 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.util.JsonUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.common.base.Strings; +import com.topjohnwu.superuser.io.SuFile; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.util.ArrayList; +import java.util.List; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +public class TwoFASImporter extends DatabaseImporter { + private static final int ITERATION_COUNT = 10_000; + private static final int KEY_SIZE = 256; // bits + + public TwoFASImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + public State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + try { + String json = new String(IOUtils.readAll(stream), StandardCharsets.UTF_8); + JSONObject obj = new JSONObject(json); + int version = obj.getInt("schemaVersion"); + if (version > 4) { + throw new DatabaseImporterException(String.format("Unsupported schema version: %d", version)); + } + + String encryptedString = JsonUtils.optString(obj, "servicesEncrypted"); + if (encryptedString == null) { + JSONArray array = obj.getJSONArray("services"); + List entries = arrayToList(array); + return new DecryptedState(entries); + } + + String[] parts = encryptedString.split(":"); + if (parts.length < 3) { + throw new DatabaseImporterException(String.format("Unexpected format of encrypted data (parts: %d)", parts.length)); + } + + byte[] data = Base64.decode(parts[0]); + byte[] salt = Base64.decode(parts[1]); + byte[] iv = Base64.decode(parts[2]); + return new EncryptedState(data, salt, iv); + } catch (IOException | JSONException e) { + throw new DatabaseImporterException(e); + } + } + + private static List arrayToList(JSONArray array) throws JSONException { + List list = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + list.add(array.getJSONObject(i)); + } + + return list; + } + + public static class EncryptedState extends State { + private final byte[] _data; + private final byte[] _salt; + private final byte[] _iv; + + private EncryptedState(byte[] data, byte[] salt, byte[] iv) { + super(true); + _data = data; + _salt = salt; + _iv = iv; + } + + private SecretKey deriveKey(char[] password) + throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(password, _salt, ITERATION_COUNT, KEY_SIZE); + SecretKey key = factory.generateSecret(spec); + return new SecretKeySpec(key.getEncoded(), "AES"); + } + + public DecryptedState decrypt(char[] password) throws DatabaseImporterException { + try { + SecretKey key = deriveKey(password); + Cipher cipher = CryptoUtils.createDecryptCipher(key, _iv); + byte[] decrypted = cipher.doFinal(_data); + String json = new String(decrypted, StandardCharsets.UTF_8); + return new DecryptedState(arrayToList(new JSONArray(json))); + } catch (BadPaddingException | JSONException e) { + throw new DatabaseImporterException(e); + } catch (NoSuchAlgorithmException + | InvalidKeySpecException + | InvalidAlgorithmParameterException + | NoSuchPaddingException + | InvalidKeyException + | IllegalBlockSizeException e) { + throw new RuntimeException(e); + } + } + + @Override + public void decrypt(Context context, DecryptListener listener) { + Dialogs.showPasswordInputDialog(context, R.string.enter_password_2fas_message, 0, password -> { + try { + DecryptedState state = decrypt(password); + listener.onStateDecrypted(state); + } catch (DatabaseImporterException e) { + listener.onError(e); + } + }, dialog -> listener.onCanceled()); + } + } + + public static class DecryptedState extends DatabaseImporter.State { + private final List _entries; + + public DecryptedState(List entries) { + super(false); + _entries = entries; + } + + @Override + public Result convert() { + Result result = new Result(); + + for (JSONObject obj : _entries) { + try { + VaultEntry entry = convertEntry(obj); + result.addEntry(entry); + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + + return result; + } + + private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { + try { + byte[] secret = GoogleAuthInfo.parseSecret(obj.getString("secret")); + JSONObject info = obj.getJSONObject("otp"); + String issuer = obj.optString("name"); + if (Strings.isNullOrEmpty(issuer)) { + issuer = info.optString("issuer"); + } + String name = info.optString("account"); + int digits = info.optInt("digits", TotpInfo.DEFAULT_DIGITS); + String algorithm = info.optString("algorithm", TotpInfo.DEFAULT_ALGORITHM); + + OtpInfo otp; + String tokenType = JsonUtils.optString(info, "tokenType"); + if (tokenType == null || tokenType.equals("TOTP")) { + int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD); + otp = new TotpInfo(secret, algorithm, digits, period); + } else if (tokenType.equals("HOTP")) { + long counter = info.optLong("counter", 0); + otp = new HotpInfo(secret, algorithm, digits, counter); + } else if (tokenType.equals("STEAM")) { + int period = info.optInt("period", TotpInfo.DEFAULT_PERIOD); + otp = new SteamInfo(secret, algorithm, digits, period); + } else { + throw new DatabaseImporterEntryException(String.format("Unrecognized tokenType: %s", tokenType), obj.toString()); + } + + return new VaultEntry(otp, name, issuer); + } catch (OtpInfoException | JSONException | EncodingException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java new file mode 100644 index 0000000..ecedf38 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java @@ -0,0 +1,47 @@ +package com.beemdevelopment.aegis.importers; + +import android.content.Context; + +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.topjohnwu.superuser.io.SuFile; + +import java.io.InputStream; + +public class WinAuthImporter extends DatabaseImporter { + public WinAuthImporter(Context context) { + super(context); + } + + @Override + protected SuFile getAppPath() { + throw new UnsupportedOperationException(); + } + + @Override + public WinAuthImporter.State read(InputStream stream, boolean isInternal) throws DatabaseImporterException { + GoogleAuthUriImporter importer = new GoogleAuthUriImporter(requireContext()); + DatabaseImporter.State state = importer.read(stream); + return new State(state); + } + + public static class State extends DatabaseImporter.State { + private DatabaseImporter.State _state; + + private State(DatabaseImporter.State state) { + super(false); + _state = state; + } + + @Override + public Result convert() throws DatabaseImporterException { + Result result = _state.convert(); + + for (VaultEntry entry : result.getEntries()) { + entry.setIssuer(entry.getName()); + entry.setName("WinAuth"); + } + + return result; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java new file mode 100644 index 0000000..f41f155 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java @@ -0,0 +1,465 @@ +package com.beemdevelopment.aegis.otp; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import com.beemdevelopment.aegis.GoogleAuthProtos; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.Base64; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class GoogleAuthInfo implements Transferable, Serializable { + public static final String SCHEME = "otpauth"; + public static final String SCHEME_EXPORT = "otpauth-migration"; + + private OtpInfo _info; + private String _accountName; + private String _issuer; + + public GoogleAuthInfo(OtpInfo info, String accountName, String issuer) { + _info = info; + _accountName = accountName; + _issuer = issuer; + } + + public static GoogleAuthInfo parseUri(String s) throws GoogleAuthInfoException { + Uri uri = Uri.parse(s); + if (uri == null) { + throw new GoogleAuthInfoException(uri, String.format("Bad URI format: %s", s)); + } + return GoogleAuthInfo.parseUri(uri); + } + + public static GoogleAuthInfo parseUri(Uri uri) throws GoogleAuthInfoException { + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equals(SCHEME) || scheme.equals(MotpInfo.SCHEME))) { + throw new GoogleAuthInfoException(uri, String.format("Unsupported protocol: %s", scheme)); + } + + // 'secret' is a required parameter + String encodedSecret = uri.getQueryParameter("secret"); + if (encodedSecret == null) { + throw new GoogleAuthInfoException(uri, "Parameter 'secret' is not present"); + } + + byte[] secret; + try { + secret = (scheme.equals(MotpInfo.SCHEME)) ? Hex.decode(encodedSecret) : parseSecret(encodedSecret); + } catch (EncodingException e) { + throw new GoogleAuthInfoException(uri, "Bad secret", e); + } + if (secret.length == 0) { + throw new GoogleAuthInfoException(uri, "Secret is empty"); + } + + OtpInfo info; + String issuer = ""; + try { + String type = (scheme.equals(MotpInfo.SCHEME)) ? MotpInfo.ID : uri.getHost(); + if (type == null) { + throw new GoogleAuthInfoException(uri, String.format("Host not present in URI: %s", uri.toString())); + } + + switch (type) { + case "totp": + TotpInfo totpInfo = new TotpInfo(secret); + String period = uri.getQueryParameter("period"); + if (period != null) { + totpInfo.setPeriod(Integer.parseInt(period)); + } + info = totpInfo; + break; + case "steam": + SteamInfo steamInfo = new SteamInfo(secret); + period = uri.getQueryParameter("period"); + if (period != null) { + steamInfo.setPeriod(Integer.parseInt(period)); + } + info = steamInfo; + break; + case "hotp": + HotpInfo hotpInfo = new HotpInfo(secret); + String counter = uri.getQueryParameter("counter"); + if (counter == null) { + throw new GoogleAuthInfoException(uri, "Parameter 'counter' is not present"); + } + hotpInfo.setCounter(Long.parseLong(counter)); + info = hotpInfo; + break; + case YandexInfo.HOST_ID: + String pin = uri.getQueryParameter("pin"); + if (pin != null) { + pin = new String(parseSecret(pin), StandardCharsets.UTF_8); + } + + info = new YandexInfo(secret, pin); + issuer = info.getType(); + break; + case MotpInfo.ID: + info = new MotpInfo(secret); + break; + default: + throw new GoogleAuthInfoException(uri, String.format("Unsupported OTP type: %s", type)); + } + } catch (OtpInfoException | NumberFormatException | EncodingException e) { + throw new GoogleAuthInfoException(uri, e); + } + + // provider info used to disambiguate accounts + String path = uri.getPath(); + String label = path != null && path.length() > 0 ? path.substring(1) : ""; + + String accountName = ""; + + if (label.contains(":")) { + // a label can only contain one colon + // it's ok to fail if that's not the case + String[] strings = label.split(":"); + if (strings.length == 2) { + issuer = strings[0]; + accountName = strings[1]; + } else { + // at this point, just dump the whole thing into the accountName + accountName = label; + } + } else { + // label only contains the account name + // grab the issuer's info from the 'issuer' parameter if it's present + String issuerParam = uri.getQueryParameter("issuer"); + if (issuer.isEmpty()) { + issuer = issuerParam != null ? issuerParam : ""; + } + accountName = label; + } + + // just use the defaults if these parameters aren't set + try { + String algorithm = uri.getQueryParameter("algorithm"); + if (algorithm != null) { + info.setAlgorithm(algorithm); + } + String digits = uri.getQueryParameter("digits"); + if (digits != null) { + info.setDigits(Integer.parseInt(digits)); + } + } catch (OtpInfoException | NumberFormatException e) { + throw new GoogleAuthInfoException(uri, e); + } + + return new GoogleAuthInfo(info, accountName, issuer); + } + + /** + * Decodes the given base 32 secret, while being tolerant of whitespace and dashes. + */ + public static byte[] parseSecret(String s) throws EncodingException { + s = s.trim().replace("-", "").replace(" ", ""); + return Base32.decode(s); + } + + public static Export parseExportUri(String s) throws GoogleAuthInfoException { + Uri uri = Uri.parse(s); + if (uri == null) { + throw new GoogleAuthInfoException(uri, "Bad URI format"); + } + return GoogleAuthInfo.parseExportUri(uri); + } + + public static Export parseExportUri(Uri uri) throws GoogleAuthInfoException { + String scheme = uri.getScheme(); + if (scheme == null || !scheme.equals(SCHEME_EXPORT)) { + throw new GoogleAuthInfoException(uri, "Unsupported protocol"); + } + + String host = uri.getHost(); + if (host == null || !host.equals("offline")) { + throw new GoogleAuthInfoException(uri, "Unsupported host"); + } + + String data = uri.getQueryParameter("data"); + if (data == null) { + throw new GoogleAuthInfoException(uri, "Parameter 'data' is not set"); + } + + GoogleAuthProtos.MigrationPayload payload; + try { + byte[] bytes = Base64.decode(data); + payload = GoogleAuthProtos.MigrationPayload.parseFrom(bytes); + } catch (EncodingException | InvalidProtocolBufferException e) { + throw new GoogleAuthInfoException(uri, e); + } + + List infos = new ArrayList<>(); + for (GoogleAuthProtos.MigrationPayload.OtpParameters params : payload.getOtpParametersList()) { + OtpInfo otp; + try { + int digits; + switch (params.getDigits()) { + case DIGIT_COUNT_UNSPECIFIED: + // intentional fallthrough + case DIGIT_COUNT_SIX: + digits = TotpInfo.DEFAULT_DIGITS; + break; + case DIGIT_COUNT_EIGHT: + digits = 8; + break; + default: + throw new GoogleAuthInfoException(uri, String.format("Unsupported digits: %d", params.getDigits().ordinal())); + } + + String algo; + switch (params.getAlgorithm()) { + case ALGORITHM_UNSPECIFIED: + // intentional fallthrough + case ALGORITHM_SHA1: + algo = "SHA1"; + break; + case ALGORITHM_SHA256: + algo = "SHA256"; + break; + case ALGORITHM_SHA512: + algo = "SHA512"; + break; + default: + throw new GoogleAuthInfoException(uri, String.format("Unsupported hash algorithm: %d", params.getAlgorithm().ordinal())); + } + + byte[] secret = params.getSecret().toByteArray(); + if (secret.length == 0) { + throw new GoogleAuthInfoException(uri, "Secret is empty"); + } + + switch (params.getType()) { + case OTP_TYPE_UNSPECIFIED: + // intentional fallthrough + case OTP_TYPE_TOTP: + otp = new TotpInfo(secret, algo, digits, TotpInfo.DEFAULT_PERIOD); + break; + case OTP_TYPE_HOTP: + otp = new HotpInfo(secret, algo, digits, params.getCounter()); + break; + default: + throw new GoogleAuthInfoException(uri, String.format("Unsupported algorithm: %d", params.getType().ordinal())); + } + } catch (OtpInfoException e) { + throw new GoogleAuthInfoException(uri, e); + } + + String name = params.getName(); + String issuer = params.getIssuer(); + int colonI = name.indexOf(':'); + if (issuer.isEmpty() && colonI != -1) { + issuer = name.substring(0, colonI); + name = name.substring(colonI + 1); + } + + GoogleAuthInfo info = new GoogleAuthInfo(otp, name, issuer); + infos.add(info); + } + + return new Export(infos, payload.getBatchId(), payload.getBatchIndex(), payload.getBatchSize()); + } + + public OtpInfo getOtpInfo() { + return _info; + } + + @Override + public Uri getUri() { + Uri.Builder builder = new Uri.Builder(); + + if (_info instanceof MotpInfo) { + builder.scheme(MotpInfo.SCHEME); + builder.appendQueryParameter("secret", Hex.encode(_info.getSecret())); + } else { + builder.scheme(SCHEME); + + if (_info instanceof TotpInfo) { + if (_info instanceof SteamInfo) { + builder.authority("steam"); + } else if (_info instanceof YandexInfo) { + builder.authority(YandexInfo.HOST_ID); + } else { + builder.authority("totp"); + } + builder.appendQueryParameter("period", Integer.toString(((TotpInfo) _info).getPeriod())); + } else if (_info instanceof HotpInfo) { + builder.authority("hotp"); + builder.appendQueryParameter("counter", Long.toString(((HotpInfo) _info).getCounter())); + } else { + throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", _info.getClass())); + } + + builder.appendQueryParameter("digits", Integer.toString(_info.getDigits())); + builder.appendQueryParameter("algorithm", _info.getAlgorithm(false)); + builder.appendQueryParameter("secret", Base32.encode(_info.getSecret())); + + if (_info instanceof YandexInfo) { + builder.appendQueryParameter("pin", Base32.encode(((YandexInfo) _info).getPin())); + } + } + + if (_issuer != null && !_issuer.equals("")) { + builder.path(String.format("%s:%s", _issuer, _accountName)); + builder.appendQueryParameter("issuer", _issuer); + } else { + builder.path(_accountName); + } + + return builder.build(); + } + + public String getIssuer() { + return _issuer; + } + + public String getAccountName() { + return _accountName; + } + + public static class Export implements Transferable, Serializable { + private int _batchId; + private int _batchIndex; + private int _batchSize; + private List _entries; + + public Export(List entries, int batchId, int batchIndex, int batchSize) { + _batchId = batchId; + _batchIndex = batchIndex; + _batchSize = batchSize; + _entries = entries; + } + + public List getEntries() { + return _entries; + } + + public int getBatchSize() { + return _batchSize; + } + + public int getBatchIndex() { + return _batchIndex; + } + + public int getBatchId() { + return _batchId; + } + + public static List getMissingIndices(@NonNull List exports) throws IllegalArgumentException { + if (!isSingleBatch(exports)) { + throw new IllegalArgumentException("Export list contains entries from different batches"); + } + + List indicesMissing = new ArrayList<>(); + if (exports.isEmpty()) { + return indicesMissing; + } + + Set indicesPresent = exports.stream() + .map(Export::getBatchIndex) + .collect(Collectors.toSet()); + + for (int i = 0; i < exports.get(0).getBatchSize(); i++) { + if (!indicesPresent.contains(i)) { + indicesMissing.add(i); + } + } + + return indicesMissing; + } + + public static boolean isSingleBatch(@NonNull List exports) { + if (exports.isEmpty()) { + return true; + } + + int batchId = exports.get(0).getBatchId(); + for (Export export : exports) { + if (export.getBatchId() != batchId) { + return false; + } + } + + return true; + } + + @Override + public Uri getUri() throws GoogleAuthInfoException { + GoogleAuthProtos.MigrationPayload.Builder builder = GoogleAuthProtos.MigrationPayload.newBuilder(); + builder.setBatchId(_batchId) + .setBatchIndex(_batchIndex) + .setBatchSize(_batchSize) + .setVersion(1); + + for (GoogleAuthInfo info: _entries) { + GoogleAuthProtos.MigrationPayload.OtpParameters.Builder parameters = GoogleAuthProtos.MigrationPayload.OtpParameters.newBuilder() + .setSecret(ByteString.copyFrom(info.getOtpInfo().getSecret())) + .setName(info.getAccountName()) + .setIssuer(info.getIssuer()); + + switch (info.getOtpInfo().getAlgorithm(false)) { + case "SHA1": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA1); + break; + case "SHA256": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA256); + break; + case "SHA512": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_SHA512); + break; + case "MD5": + parameters.setAlgorithm(GoogleAuthProtos.MigrationPayload.Algorithm.ALGORITHM_MD5); + break; + default: + throw new GoogleAuthInfoException(info.getUri(), String.format("Unsupported Algorithm: %s", info.getOtpInfo().getAlgorithm(false))); + } + + switch (info.getOtpInfo().getDigits()) { + case 6: + parameters.setDigits(GoogleAuthProtos.MigrationPayload.DigitCount.DIGIT_COUNT_SIX); + break; + case 8: + parameters.setDigits(GoogleAuthProtos.MigrationPayload.DigitCount.DIGIT_COUNT_EIGHT); + break; + default: + throw new GoogleAuthInfoException(info.getUri(), String.format("Unsupported number of digits: %s", info.getOtpInfo().getDigits())); + } + + switch (info.getOtpInfo().getType().toLowerCase()) { + case HotpInfo.ID: + parameters.setType(GoogleAuthProtos.MigrationPayload.OtpType.OTP_TYPE_HOTP); + parameters.setCounter(((HotpInfo) info.getOtpInfo()).getCounter()); + break; + case TotpInfo.ID: + parameters.setType(GoogleAuthProtos.MigrationPayload.OtpType.OTP_TYPE_TOTP); + break; + default: + throw new GoogleAuthInfoException(info.getUri(), String.format("Type unsupported by GoogleAuthProtos: %s", info.getOtpInfo().getType())); + } + + builder.addOtpParameters(parameters.build()); + } + + Uri.Builder exportUriBuilder = new Uri.Builder() + .scheme(SCHEME_EXPORT) + .authority("offline"); + + String data = Base64.encode(builder.build().toByteArray()); + exportUriBuilder.appendQueryParameter("data", data); + + return exportUriBuilder.build(); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java new file mode 100644 index 0000000..c912a5d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java @@ -0,0 +1,41 @@ +package com.beemdevelopment.aegis.otp; + +import android.net.Uri; + +public class GoogleAuthInfoException extends Exception { + private final Uri _uri; + + public GoogleAuthInfoException(Uri uri, Throwable cause) { + super(cause); + _uri = uri; + } + + public GoogleAuthInfoException(Uri uri, String message) { + super(message); + _uri = uri; + } + + public GoogleAuthInfoException(Uri uri, String message, Throwable cause) { + super(message, cause); + _uri = uri; + } + + /** + * Reports whether the scheme of the URI is phonefactor://. + */ + public boolean isPhoneFactor() { + return _uri != null && _uri.getScheme() != null && _uri.getScheme().equals("phonefactor"); + } + + @Override + public String getMessage() { + Throwable cause = getCause(); + if (cause == null + || this == cause + || (super.getMessage() != null && super.getMessage().equals(cause.getMessage()))) { + return super.getMessage(); + } + + return String.format("%s (%s)", super.getMessage(), cause.getMessage()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java new file mode 100644 index 0000000..7fc91fc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java @@ -0,0 +1,88 @@ +package com.beemdevelopment.aegis.otp; + +import com.beemdevelopment.aegis.crypto.otp.HOTP; +import com.beemdevelopment.aegis.crypto.otp.OTP; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +public class HotpInfo extends OtpInfo { + public static final String ID = "hotp"; + public static final int DEFAULT_COUNTER = 0; + + private long _counter; + + public HotpInfo(byte[] secret, long counter) throws OtpInfoException { + super(secret); + setCounter(counter); + } + + public HotpInfo(byte[] secret) throws OtpInfoException { + this(secret, DEFAULT_COUNTER); + } + + public HotpInfo(byte[] secret, String algorithm, int digits, long counter) throws OtpInfoException { + super(secret, algorithm, digits); + setCounter(counter); + } + + @Override + public String getOtp() throws OtpInfoException { + checkSecret(); + + try { + OTP otp = HOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getCounter()); + return otp.toString(); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public JSONObject toJson() { + JSONObject obj = super.toJson(); + try { + obj.put("counter", getCounter()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return obj; + } + + public long getCounter() { + return _counter; + } + + public static boolean isCounterValid(long counter) { + return counter >= 0; + } + + public void setCounter(long counter) throws OtpInfoException { + if (!isCounterValid(counter)) { + throw new OtpInfoException(String.format("bad counter: %d", counter)); + } + _counter = counter; + } + + public void incrementCounter() throws OtpInfoException { + setCounter(getCounter() + 1); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof HotpInfo)) { + return false; + } + + HotpInfo info = (HotpInfo) o; + return super.equals(o) && getCounter() == info.getCounter(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java new file mode 100644 index 0000000..585d548 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java @@ -0,0 +1,81 @@ +package com.beemdevelopment.aegis.otp; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.crypto.otp.MOTP; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +public class MotpInfo extends TotpInfo { + public static final String ID = "motp"; + public static final String SCHEME = "motp"; + public static final String ALGORITHM = "MD5"; + + public static final int PERIOD = 10; + public static final int DIGITS = 6; + + private String _pin; + + public MotpInfo(@NonNull byte[] secret) throws OtpInfoException { + this(secret, null); + } + + public MotpInfo(byte[] secret, String pin) throws OtpInfoException { + super(secret, ALGORITHM, DIGITS, PERIOD); + setPin(pin); + } + + @Override + public String getOtp(long time) { + if (_pin == null) { + throw new IllegalStateException("PIN must be set before generating an OTP"); + } + + try { + MOTP otp = MOTP.generateOTP(getSecret(), getAlgorithm(false), getDigits(), getPeriod(), getPin(), time); + return otp.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public JSONObject toJson() { + JSONObject result = super.toJson(); + try { + result.put("pin", getPin()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return result; + } + + @Nullable + public String getPin() { + return _pin; + } + + public void setPin(@NonNull String pin) { + this._pin = pin; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof MotpInfo)) { + return false; + } + + MotpInfo info = (MotpInfo) o; + return super.equals(o) && Objects.equals(getPin(), info.getPin()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java new file mode 100644 index 0000000..3fbaa96 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java @@ -0,0 +1,162 @@ +package com.beemdevelopment.aegis.otp; + +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Locale; + +public abstract class OtpInfo implements Serializable { + public static final int DEFAULT_DIGITS = 6; + public static final String DEFAULT_ALGORITHM = "SHA1"; + + private byte[] _secret; + private String _algorithm; + private int _digits; + + public OtpInfo(byte[] secret) throws OtpInfoException { + this(secret, DEFAULT_ALGORITHM, DEFAULT_DIGITS); + } + + public OtpInfo(byte[] secret, String algorithm, int digits) throws OtpInfoException { + setSecret(secret); + setAlgorithm(algorithm); + setDigits(digits); + } + + public abstract String getOtp() throws OtpInfoException; + + protected void checkSecret() throws OtpInfoException { + if (getSecret().length == 0) { + throw new OtpInfoException("Secret is empty"); + } + } + + public abstract String getTypeId(); + + public String getType() { + return getTypeId().toUpperCase(Locale.ROOT); + } + + public JSONObject toJson() { + JSONObject obj = new JSONObject(); + + try { + obj.put("secret", Base32.encode(getSecret())); + obj.put("algo", getAlgorithm(false)); + obj.put("digits", getDigits()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + return obj; + } + + public byte[] getSecret() { + return _secret; + } + + public String getAlgorithm(boolean java) { + if (java) { + return "Hmac" + _algorithm; + } + return _algorithm; + } + + public int getDigits() { + return _digits; + } + + public void setSecret(byte[] secret) { + _secret = secret; + } + + public static boolean isAlgorithmValid(String algorithm) { + return algorithm.equals("SHA1") || algorithm.equals("SHA256") || + algorithm.equals("SHA512") || algorithm.equals("MD5"); + } + + public void setAlgorithm(String algorithm) throws OtpInfoException { + if (algorithm.startsWith("Hmac")) { + algorithm = algorithm.substring(4); + } + algorithm = algorithm.toUpperCase(Locale.ROOT); + + if (!isAlgorithmValid(algorithm)) { + throw new OtpInfoException(String.format("unsupported algorithm: %s", algorithm)); + } + _algorithm = algorithm; + } + + public static boolean isDigitsValid(int digits) { + // allow a max of 10 digits, as truncation will only extract 31 bits + return digits > 0 && digits <= 10; + } + + public void setDigits(int digits) throws OtpInfoException { + if (!isDigitsValid(digits)) { + throw new OtpInfoException(String.format("unsupported amount of digits: %d", digits)); + } + _digits = digits; + } + + public static OtpInfo fromJson(String type, JSONObject obj) throws OtpInfoException { + OtpInfo info; + + try { + byte[] secret = Base32.decode(obj.getString("secret")); + String algo = obj.getString("algo"); + int digits = obj.getInt("digits"); + + // Special case to work around a bug where a user could accidentally + // set the hash algorithm of a non-mOTP entry to MD5 + if (!type.equals(MotpInfo.ID) && algo.equals("MD5")) { + algo = DEFAULT_ALGORITHM; + } + + switch (type) { + case TotpInfo.ID: + info = new TotpInfo(secret, algo, digits, obj.getInt("period")); + break; + case SteamInfo.ID: + info = new SteamInfo(secret, algo, digits, obj.getInt("period")); + break; + case HotpInfo.ID: + info = new HotpInfo(secret, algo, digits, obj.getLong("counter")); + break; + case YandexInfo.ID: + info = new YandexInfo(secret, obj.getString("pin")); + break; + case MotpInfo.ID: + info = new MotpInfo(secret, obj.getString("pin")); + break; + default: + throw new OtpInfoException("unsupported otp type: " + type); + } + } catch (EncodingException | JSONException e) { + throw new OtpInfoException(e); + } + + return info; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof OtpInfo)) { + return false; + } + + OtpInfo info = (OtpInfo) o; + return getTypeId().equals(info.getTypeId()) + && Arrays.equals(getSecret(), info.getSecret()) + && getAlgorithm(false).equals(info.getAlgorithm(false)) + && getDigits() == info.getDigits(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfoException.java b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfoException.java new file mode 100644 index 0000000..12ec616 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfoException.java @@ -0,0 +1,11 @@ +package com.beemdevelopment.aegis.otp; + +public class OtpInfoException extends Exception { + public OtpInfoException(Throwable cause) { + super(cause); + } + + public OtpInfoException(String message) { + super(message); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java new file mode 100644 index 0000000..1adcd98 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java @@ -0,0 +1,44 @@ +package com.beemdevelopment.aegis.otp; + +import com.beemdevelopment.aegis.crypto.otp.OTP; +import com.beemdevelopment.aegis.crypto.otp.TOTP; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; + +public class SteamInfo extends TotpInfo { + public static final String ID = "steam"; + public static final int DIGITS = 5; + + public SteamInfo(byte[] secret) throws OtpInfoException { + super(secret, OtpInfo.DEFAULT_ALGORITHM, DIGITS, TotpInfo.DEFAULT_PERIOD); + } + + public SteamInfo(byte[] secret, String algorithm, int digits, int period) throws OtpInfoException { + super(secret, algorithm, digits, period); + } + + @Override + public String getOtp(long time) throws OtpInfoException { + checkSecret(); + + try { + OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod(), time); + return otp.toSteamString(); + } catch (InvalidKeyException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public String getType() { + String id = getTypeId(); + return id.substring(0, 1).toUpperCase(Locale.ROOT) + id.substring(1); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java new file mode 100644 index 0000000..cba249e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java @@ -0,0 +1,98 @@ +package com.beemdevelopment.aegis.otp; + +import com.beemdevelopment.aegis.crypto.otp.OTP; +import com.beemdevelopment.aegis.crypto.otp.TOTP; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +public class TotpInfo extends OtpInfo { + public static final String ID = "totp"; + public static final int DEFAULT_PERIOD = 30; + + private int _period; + + public TotpInfo(byte[] secret) throws OtpInfoException { + super(secret); + setPeriod(DEFAULT_PERIOD); + } + + public TotpInfo(byte[] secret, String algorithm, int digits, int period) throws OtpInfoException { + super(secret, algorithm, digits); + setPeriod(period); + } + + @Override + public String getOtp() throws OtpInfoException { + return getOtp(System.currentTimeMillis() / 1000); + } + + public String getOtp(long time) throws OtpInfoException { + checkSecret(); + + try { + OTP otp = TOTP.generateOTP(getSecret(), getAlgorithm(true), getDigits(), getPeriod(), time); + return otp.toString(); + } catch (InvalidKeyException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public JSONObject toJson() { + JSONObject obj = super.toJson(); + try { + obj.put("period", getPeriod()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return obj; + } + + public int getPeriod() { + return _period; + } + + public static boolean isPeriodValid(int period) { + if (period <= 0) { + return false; + } + + // check for the possibility of an overflow when converting to milliseconds + return period <= Integer.MAX_VALUE / 1000; + } + + public void setPeriod(int period) throws OtpInfoException { + if (!isPeriodValid(period)) { + throw new OtpInfoException(String.format("bad period: %d", period)); + } + _period = period; + } + + public long getMillisTillNextRotation() { + return TotpInfo.getMillisTillNextRotation(_period); + } + + public static long getMillisTillNextRotation(int period) { + long p = period * 1000; + return p - (System.currentTimeMillis() % p); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof TotpInfo)) { + return false; + } + + TotpInfo info = (TotpInfo) o; + return super.equals(o) && getPeriod() == info.getPeriod(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java b/app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java new file mode 100644 index 0000000..475db07 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java @@ -0,0 +1,7 @@ +package com.beemdevelopment.aegis.otp; + +import android.net.Uri; + +public interface Transferable { + Uri getUri() throws GoogleAuthInfoException; +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java b/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java new file mode 100644 index 0000000..0afdf61 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java @@ -0,0 +1,188 @@ +package com.beemdevelopment.aegis.otp; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.crypto.otp.YAOTP; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; + +public class YandexInfo extends TotpInfo { + public static final String DEFAULT_ALGORITHM = "SHA256"; + public static final int DIGITS = 8; + + public static final int SECRET_LENGTH = 16; + public static final int SECRET_FULL_LENGTH = 26; + public static final String ID = "yandex"; + public static final String HOST_ID = "yaotp"; + + @Nullable + private String _pin; + + public YandexInfo(@NonNull byte[] secret) throws OtpInfoException { + this(secret, null); + } + + public YandexInfo(@NonNull byte[] secret, @Nullable String pin) throws OtpInfoException { + super(secret, DEFAULT_ALGORITHM, DIGITS, TotpInfo.DEFAULT_PERIOD); + setSecret(parseSecret(secret)); + _pin = pin; + } + + @Override + public String getOtp(long time) { + if (_pin == null) { + throw new IllegalStateException("PIN must be set before generating an OTP"); + } + + try { + YAOTP otp = YAOTP.generateOTP(getSecret(), getPin(), getDigits(), getAlgorithm(true), getPeriod(), time); + return otp.toString(); + } catch (InvalidKeyException | NoSuchAlgorithmException | IOException e) { + throw new RuntimeException(e); + } + } + + @Nullable + public String getPin() { + return _pin; + } + + public void setPin(@NonNull String pin) { + _pin = pin; + } + + @Override + public String getTypeId() { + return ID; + } + + @Override + public String getType() { + String id = getTypeId(); + return id.substring(0, 1).toUpperCase(Locale.ROOT) + id.substring(1); + } + + @Override + public JSONObject toJson() { + JSONObject result = super.toJson(); + try { + result.put("pin", getPin()); + } catch (JSONException e) { + throw new RuntimeException(e); + } + return result; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof YandexInfo)) { + return false; + } + + YandexInfo info = (YandexInfo) o; + return super.equals(o) && Objects.equals(getPin(), info.getPin()); + } + + public static byte[] parseSecret(byte[] secret) throws OtpInfoException { + validateSecret(secret); + + if (secret.length != SECRET_LENGTH) { + return Arrays.copyOfRange(secret, 0, SECRET_LENGTH); + } + + return secret; + } + + /** + * Java implementation of ChecksumIsValid + * From: https://github.com/norblik/KeeYaOtp/blob/188a1a99f13f82e4ef8df8a1b9b9351ba236e2a1/KeeYaOtp/Core/Secret.cs + * License: GPLv3+ + */ + public static void validateSecret(byte[] secret) throws OtpInfoException { + if (secret.length != SECRET_LENGTH && secret.length != SECRET_FULL_LENGTH) { + throw new OtpInfoException(String.format("Invalid Yandex secret length: %d bytes", secret.length)); + } + + // Secrets originating from a QR code do not have a checksum, so we assume those are valid + if (secret.length == SECRET_LENGTH) { + return; + } + + char originalChecksum = (char) ((secret[secret.length - 2] & 0x0F) << 8 | secret[secret.length - 1] & 0xff); + + char accum = 0; + int accumBits = 0; + + int inputTotalBitsAvailable = secret.length * 8 - 12; + int inputIndex = 0; + int inputBitsAvailable = 8; + + while (inputTotalBitsAvailable > 0) { + int requiredBits = 13 - accumBits; + if (inputTotalBitsAvailable < requiredBits) { + requiredBits = inputTotalBitsAvailable; + } + + while (requiredBits > 0) { + int curInput = (secret[inputIndex] & (1 << inputBitsAvailable) - 1) & 0xff; + int bitsToRead = Math.min(requiredBits, inputBitsAvailable); + + curInput >>= inputBitsAvailable - bitsToRead; + accum = (char) (accum << bitsToRead | curInput); + + inputTotalBitsAvailable -= bitsToRead; + requiredBits -= bitsToRead; + inputBitsAvailable -= bitsToRead; + accumBits += bitsToRead; + + if (inputBitsAvailable == 0) { + inputIndex += 1; + inputBitsAvailable = 8; + } + } + + if (accumBits == 13) { + accum ^= 0b1_1000_1111_0011; + } + accumBits = 16 - getNumberOfLeadingZeros(accum); + } + + if (accum != originalChecksum) { + throw new OtpInfoException("Yandex secret checksum invalid"); + } + } + + private static int getNumberOfLeadingZeros(char value) { + if (value == 0) { + return 16; + } + + int n = 0; + if ((value & 0xFF00) == 0) { + n += 8; + value <<= 8; + } + if ((value & 0xF000) == 0) { + n += 4; + value <<= 4; + } + if ((value & 0xC000) == 0) { + n += 2; + value <<= 2; + } + if ((value & 0x8000) == 0) { + n++; + } + + return n; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java b/app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java new file mode 100644 index 0000000..aada5b2 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java @@ -0,0 +1,35 @@ +package com.beemdevelopment.aegis.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.beemdevelopment.aegis.BuildConfig; +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.vault.VaultManager; + +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + +@AndroidEntryPoint +public class VaultLockReceiver extends BroadcastReceiver { + public static final String ACTION_LOCK_VAULT + = String.format("%s.LOCK_VAULT", BuildConfig.APPLICATION_ID); + + @Inject + protected VaultManager _vaultManager; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() == null + || (!intent.getAction().equals(ACTION_LOCK_VAULT) + && !intent.getAction().equals(Intent.ACTION_SCREEN_OFF))) { + return; + } + + if (_vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_DEVICE_LOCK)) { + _vaultManager.lock(false); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java new file mode 100644 index 0000000..036c2c1 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java @@ -0,0 +1,44 @@ +package com.beemdevelopment.aegis.services; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; + +import androidx.annotation.RequiresApi; + +import com.beemdevelopment.aegis.ui.MainActivity; + +@RequiresApi(api = Build.VERSION_CODES.N) +public class LaunchAppTileService extends TileService { + + @Override + public void onStartListening() { + super.onStartListening(); + Tile tile = getQsTile(); + if (tile != null) { + tile.setState(Tile.STATE_INACTIVE); + tile.updateTile(); + } + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + @Override + public void onClick() { + super.onClick(); + + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction(Intent.ACTION_MAIN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, flags); + startActivityAndCollapse(pendingIntent); + } else { + startActivityAndCollapse(intent); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java new file mode 100644 index 0000000..d026856 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java @@ -0,0 +1,45 @@ +package com.beemdevelopment.aegis.services; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; + +import androidx.annotation.RequiresApi; + +import com.beemdevelopment.aegis.ui.MainActivity; + +@RequiresApi(api = Build.VERSION_CODES.N) +public class LaunchScannerTileService extends TileService { + + @Override + public void onStartListening() { + super.onStartListening(); + Tile tile = getQsTile(); + if (tile != null) { + tile.setState(Tile.STATE_INACTIVE); + tile.updateTile(); + } + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + @Override + public void onClick() { + super.onClick(); + + Intent intent = new Intent(this, MainActivity.class); + intent.putExtra("action", "scan"); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.setAction(Intent.ACTION_MAIN); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, flags); + startActivityAndCollapse(pendingIntent); + } else { + startActivityAndCollapse(intent); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java b/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java new file mode 100644 index 0000000..bcc9030 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java @@ -0,0 +1,67 @@ +package com.beemdevelopment.aegis.services; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.beemdevelopment.aegis.BuildConfig; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.receivers.VaultLockReceiver; + +public class NotificationService extends Service { + private static final int NOTIFICATION_VAULT_UNLOCKED = 1; + + private static final String CHANNEL_ID = "lock_status_channel"; + + @Override + public int onStartCommand(Intent intent,int flags, int startId){ + super.onStartCommand(intent, flags, startId); + serviceMethod(); + return Service.START_STICKY; + } + + @SuppressLint("LaunchActivityFromNotification") + public void serviceMethod() { + int flags = PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE; + Intent intent = new Intent(this, VaultLockReceiver.class); + intent.setAction(VaultLockReceiver.ACTION_LOCK_VAULT); + intent.setPackage(BuildConfig.APPLICATION_ID); + PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 1, intent, flags); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_aegis_notification) + .setContentTitle(getString(R.string.app_name_full)) + .setContentText(getString(R.string.vault_unlocked_state)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setOngoing(true) + .setContentIntent(pendingIntent); + + // NOTE: Disabled for now. See issue: #1047 + //startForeground(NOTIFICATION_VAULT_UNLOCKED, builder.build()); + } + + @Override + public void onDestroy() { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); + notificationManager.cancel(NOTIFICATION_VAULT_UNLOCKED); + super.onDestroy(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + stopSelf(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java new file mode 100644 index 0000000..4b974b6 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java @@ -0,0 +1,160 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.AttrRes; +import androidx.annotation.StringRes; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.beemdevelopment.aegis.BuildConfig; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ui.dialogs.ChangelogDialog; +import com.beemdevelopment.aegis.ui.dialogs.LicenseDialog; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.google.android.material.color.MaterialColors; + +public class AboutActivity extends AegisActivity { + + private static String GITHUB = "https://github.com/beemdevelopment/Aegis"; + private static String WEBSITE_ALEXANDER = "https://alexbakker.me"; + private static String GITHUB_MICHAEL = "https://github.com/michaelschattgen"; + + private static String MAIL_BEEMDEVELOPMENT = "beemdevelopment@gmail.com"; + private static String WEBSITE_BEEMDEVELOPMENT = "https://beem.dev/"; + private static String PLAYSTORE_BEEMDEVELOPMENT = "https://play.google.com/store/apps/details?id=com.beemdevelopment.aegis"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + + setContentView(R.layout.activity_about); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + View btnLicense = findViewById(R.id.btn_license); + btnLicense.setOnClickListener(v -> { + LicenseDialog.create() + .setTheme(_themeHelper.getConfiguredTheme()) + .show(getSupportFragmentManager(), null); + }); + + View btnThirdPartyLicenses = findViewById(R.id.btn_third_party_licenses); + btnThirdPartyLicenses.setOnClickListener(v -> { + Intent intent = new Intent(this, LicensesActivity.class); + startActivity(intent); + }); + + TextView appVersion = findViewById(R.id.app_version); + appVersion.setText(getCurrentAppVersion()); + + View btnAppVersion = findViewById(R.id.btn_app_version); + btnAppVersion.setOnClickListener(v -> { + copyToClipboard(getCurrentAppVersion(), R.string.version_copied); + }); + + View btnGithub = findViewById(R.id.btn_github); + btnGithub.setOnClickListener(v -> openUrl(GITHUB)); + + View btnAlexander = findViewById(R.id.btn_alexander); + btnAlexander.setOnClickListener(v -> openUrl(WEBSITE_ALEXANDER)); + + View btnMichael = findViewById(R.id.btn_michael); + btnMichael.setOnClickListener(v -> openUrl(GITHUB_MICHAEL)); + + View btnMail = findViewById(R.id.btn_email); + btnMail.setOnClickListener(v -> openMail(MAIL_BEEMDEVELOPMENT)); + + View btnWebsite = findViewById(R.id.btn_website); + btnWebsite.setOnClickListener(v -> openUrl(WEBSITE_BEEMDEVELOPMENT)); + + View btnRate = findViewById(R.id.btn_rate); + btnRate.setOnClickListener(v -> openUrl(PLAYSTORE_BEEMDEVELOPMENT )); + + View btnChangelog = findViewById(R.id.btn_changelog); + btnChangelog.setOnClickListener(v -> { + ChangelogDialog.create() + .setTheme(_themeHelper.getConfiguredTheme()) + .show(getSupportFragmentManager(), null); + }); + + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.about_scroll_view), (targetView, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + targetView.setPadding( + 0, + 0, + 0, + insets.bottom + ); + return WindowInsetsCompat.CONSUMED; + }); + } + + private static String getCurrentAppVersion() { + if (BuildConfig.DEBUG) { + return String.format("%s-%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.GIT_HASH, BuildConfig.GIT_BRANCH); + } + + return BuildConfig.VERSION_NAME; + } + + private void openUrl(String url) { + Intent browserIntent = new Intent(Intent.ACTION_VIEW); + browserIntent.setData(Uri.parse(url)); + browserIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + startActivity(browserIntent); + } + + private void copyToClipboard(String text, @StringRes int messageId) { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData data = ClipData.newPlainText("text/plain", text); + clipboard.setPrimaryClip(data); + Toast.makeText(this, messageId, Toast.LENGTH_SHORT).show(); + } + + private void openMail(String mailaddress) { + Intent mailIntent = new Intent(Intent.ACTION_SENDTO); + mailIntent.setData(Uri.parse("mailto:" + mailaddress)); + mailIntent.putExtra(Intent.EXTRA_EMAIL, mailaddress); + mailIntent.putExtra(Intent.EXTRA_SUBJECT, R.string.app_name_full); + + startActivity(Intent.createChooser(mailIntent, getString(R.string.email))); + } + + private String getThemeColorAsHex(@AttrRes int attributeId) { + int color = MaterialColors.getColor(this, attributeId, getClass().getCanonicalName()); + return String.format("%06X", 0xFFFFFF & color); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + break; + default: + return super.onOptionsItemSelected(item); + } + + return true; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java new file mode 100644 index 0000000..5a467fb --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java @@ -0,0 +1,255 @@ +package com.beemdevelopment.aegis.ui; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.Toast; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.core.view.ViewPropertyAnimatorCompat; + +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ThemeMap; +import com.beemdevelopment.aegis.database.AuditLogRepository; +import com.beemdevelopment.aegis.helpers.ThemeHelper; +import com.beemdevelopment.aegis.icons.IconPackManager; +import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.color.MaterialColors; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Locale; + +import javax.inject.Inject; + +import dagger.hilt.InstallIn; +import dagger.hilt.android.AndroidEntryPoint; +import dagger.hilt.android.EarlyEntryPoint; +import dagger.hilt.android.EarlyEntryPoints; +import dagger.hilt.components.SingletonComponent; + +@AndroidEntryPoint +public abstract class AegisActivity extends AppCompatActivity implements VaultManager.LockListener { + protected Preferences _prefs; + protected ThemeHelper _themeHelper; + + @Inject + protected VaultManager _vaultManager; + + @Inject + protected AuditLogRepository _auditLogRepository; + + @Inject + protected IconPackManager _iconPackManager; + + private ActionModeStatusGuardHack _statusGuardHack; + + @Override + protected void onCreate(Bundle savedInstanceState) { + // set the theme and locale before creating the activity + _prefs = EarlyEntryPoints.get(getApplicationContext(), PrefEntryPoint.class).getPreferences(); + _themeHelper = new ThemeHelper(this, _prefs); + onSetTheme(); + setLocale(_prefs.getLocale()); + super.onCreate(savedInstanceState); + + _statusGuardHack = new ActionModeStatusGuardHack(); + + // set FLAG_SECURE on the window of every AegisActivity + if (_prefs.isSecureScreenEnabled()) { + getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + + // register a callback to listen for lock events + _vaultManager.registerLockListener(this); + } + + @Override + @CallSuper + protected void onDestroy() { + _vaultManager.unregisterLockListener(this); + super.onDestroy(); + } + + @CallSuper + @Override + protected void onResume() { + super.onResume(); + _vaultManager.setBlockAutoLock(false); + } + + @SuppressLint("SoonBlockedPrivateApi") + @SuppressWarnings("JavaReflectionMemberAccess") + @Override + public void onLocked(boolean userInitiated) { + setResult(RESULT_CANCELED, null); + + try { + // Call a private overload of the finish() method to prevent the app + // from disappearing from the recent apps menu + Method method = Activity.class.getDeclaredMethod("finish", int.class); + method.setAccessible(true); + method.invoke(this, 2); // FINISH_TASK_WITH_ACTIVITY = 2 + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + // On recent Android versions, the overload of the finish() method + // used above is no longer accessible + finishAndRemoveTask(); + } + } + + /** + * Called when the activity is expected to set its theme. + */ + protected void onSetTheme() { + _themeHelper.setTheme(ThemeMap.DEFAULT); + } + + protected void setLocale(Locale locale) { + Locale.setDefault(locale); + + Configuration config = new Configuration(); + config.locale = locale; + + getResources().updateConfiguration(config, getResources().getDisplayMetrics()); + } + + protected boolean saveVault() { + try { + _vaultManager.save(); + return true; + } catch (VaultRepositoryException e) { + Toast.makeText(this, getString(R.string.saving_error), Toast.LENGTH_LONG).show(); + return false; + } + } + + protected boolean saveAndBackupVault() { + try { + _vaultManager.saveAndBackup(); + return true; + } catch (VaultRepositoryException e) { + Toast.makeText(this, getString(R.string.saving_error), Toast.LENGTH_LONG).show(); + return false; + } + } + + /** + * Closes this activity if it has become an orphan (isOrphan() == true) and launches MainActivity. + * @param savedInstanceState the bundle passed to onCreate. + * @return whether to abort onCreate. + */ + protected boolean abortIfOrphan(Bundle savedInstanceState) { + if (savedInstanceState == null || !isOrphan()) { + return false; + } + + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); + return true; + } + + @Override + public void onSupportActionModeStarted(@NonNull ActionMode mode) { + super.onSupportActionModeStarted(mode); + _statusGuardHack.apply(View.VISIBLE); + } + + @Override + public void onSupportActionModeFinished(@NonNull ActionMode mode) { + super.onSupportActionModeFinished(mode); + _statusGuardHack.apply(View.GONE); + } + + /** + * When starting/finishing an action mode, forcefully cancel the fade in/out animation and + * set the status bar color. This requires the abc_decor_view_status_guard colors to be set + * to transparent. + * + * This should fix any inconsistencies between the color of the action bar and the status bar + * when an action mode is active. + */ + private class ActionModeStatusGuardHack { + private Field _fadeAnimField; + private Field _actionModeViewField; + private Drawable _appBarBackground; + + private ActionModeStatusGuardHack() { + try { + _fadeAnimField = getDelegate().getClass().getDeclaredField("mFadeAnim"); + _fadeAnimField.setAccessible(true); + _actionModeViewField = getDelegate().getClass().getDeclaredField("mActionModeView"); + _actionModeViewField.setAccessible(true); + } catch (NoSuchFieldException ignored) { + } + } + + private void apply(int visibility) { + if (_fadeAnimField == null || _actionModeViewField == null) { + return; + } + + ViewPropertyAnimatorCompat fadeAnim; + ViewGroup actionModeView; + try { + fadeAnim = (ViewPropertyAnimatorCompat) _fadeAnimField.get(getDelegate()); + actionModeView = (ViewGroup) _actionModeViewField.get(getDelegate()); + } catch (IllegalAccessException e) { + return; + } + + AppBarLayout appBarLayout = findViewById(R.id.app_bar_layout); + if (appBarLayout != null && _appBarBackground == null) { + _appBarBackground = appBarLayout.getBackground(); + } + + if (fadeAnim == null || actionModeView == null || appBarLayout == null || _appBarBackground == null) { + return; + } + + fadeAnim.cancel(); + + if (visibility == View.VISIBLE) { + actionModeView.setVisibility(visibility); + actionModeView.setAlpha(1f); + int color = MaterialColors.getColor(appBarLayout, com.google.android.material.R.attr.colorSurfaceContainer); + appBarLayout.setBackgroundColor(color); + } else { + actionModeView.setVisibility(visibility); + actionModeView.setAlpha(0f); + appBarLayout.setBackground(_appBarBackground); + } + } + } + + /** + * Reports whether this Activity instance has become an orphan. This can happen if + * the vault was killed/locked by an external trigger while the Activity was still open. + */ + private boolean isOrphan() { + return !(this instanceof MainActivity) + && !(this instanceof AuthActivity) + && !(this instanceof IntroActivity) + && !_vaultManager.isVaultLoaded(); + } + + @EarlyEntryPoint + @InstallIn(SingletonComponent.class) + public interface PrefEntryPoint { + Preferences getPreferences(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java new file mode 100644 index 0000000..024f920 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java @@ -0,0 +1,281 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Intent; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.MetricsHelper; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog; +import com.beemdevelopment.aegis.ui.glide.GlideHelper; +import com.beemdevelopment.aegis.ui.models.AssignIconEntry; +import com.beemdevelopment.aegis.ui.views.AssignIconAdapter; +import com.beemdevelopment.aegis.ui.views.IconAdapter; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.bumptech.glide.Glide; +import com.bumptech.glide.ListPreloader; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader; +import com.bumptech.glide.util.ViewPreloadSizeProvider; +import com.google.android.material.bottomsheet.BottomSheetDialog; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +public class AssignIconsActivity extends AegisActivity implements AssignIconAdapter.Listener { + private AssignIconAdapter _adapter; + private ArrayList _entries = new ArrayList<>(); + private RecyclerView _entriesView; + private AssignIconsActivity.BackPressHandler _backPressHandler; + private ViewPreloadSizeProvider _preloadSizeProvider; + private IconPack _favoriteIconPack; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + + setContentView(R.layout.activity_assign_icons); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + ArrayList assignIconEntriesIds = (ArrayList) getIntent().getSerializableExtra("entries"); + for (UUID entryId: assignIconEntriesIds) { + VaultEntry vaultEntry = _vaultManager.getVault().getEntryByUUID(entryId); + _entries.add(new AssignIconEntry(vaultEntry)); + } + + _backPressHandler = new AssignIconsActivity.BackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + + IconPreloadProvider modelProvider1 = new IconPreloadProvider(); + EntryIconPreloadProvider modelProvider2 = new EntryIconPreloadProvider(); + _preloadSizeProvider = new ViewPreloadSizeProvider<>(); + RecyclerViewPreloader preloader1 = new RecyclerViewPreloader(this, modelProvider1, _preloadSizeProvider, 10); + RecyclerViewPreloader preloader2 = new RecyclerViewPreloader(this, modelProvider2, _preloadSizeProvider, 10); + + _adapter = new AssignIconAdapter(this); + _entriesView = findViewById(R.id.list_assign_icons); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + _entriesView.setLayoutManager(layoutManager); + _entriesView.setAdapter(_adapter); + _entriesView.setNestedScrollingEnabled(false); + _entriesView.addItemDecoration(new SpacesItemDecoration(8)); + _entriesView.addOnScrollListener(preloader1); + _entriesView.addOnScrollListener(preloader2); + + Optional favoriteIconPack = _iconPackManager.getIconPacks().stream() + .sorted(Comparator.comparing(IconPack::getName)) + .findFirst(); + + if (!favoriteIconPack.isPresent()) { + throw new RuntimeException(String.format("Started %s without any icon packs present", AssignIconsActivity.class.getName())); + } + + _favoriteIconPack = favoriteIconPack.get(); + + for (AssignIconEntry entry : _entries) { + IconPack.Icon suggestedIcon = findSuggestedIcon(entry); + if (suggestedIcon != null) { + entry.setNewIcon(suggestedIcon); + } + } + + _adapter.addEntries(_entries); + } + + private IconPack.Icon findSuggestedIcon(AssignIconEntry entry) { + List suggestedIcons = _favoriteIconPack.getSuggestedIcons(entry.getEntry().getIssuer()); + if (suggestedIcons.size() > 0) { + return suggestedIcons.get(0); + } + + return null; + } + + private void saveAndFinish() throws IOException { + ArrayList uuids = new ArrayList<>(); + for (AssignIconEntry selectedEntry : _entries) { + VaultEntry entry = selectedEntry.getEntry(); + if (selectedEntry.getNewIcon() != null) { + byte[] iconBytes; + try (FileInputStream inStream = new FileInputStream(selectedEntry.getNewIcon().getFile())){ + iconBytes = IOUtils.readFile(inStream); + } + + VaultEntryIcon icon = new VaultEntryIcon(iconBytes, selectedEntry.getNewIcon().getIconType()); + entry.setIcon(icon); + uuids.add(entry.getUUID()); + + _vaultManager.getVault().replaceEntry(entry); + } + } + + Intent intent = new Intent(); + intent.putExtra("entryUUIDs", uuids); + + if (saveAndBackupVault()) { + setResult(RESULT_OK, intent); + finish(); + } + } + + private void discardAndFinish() { + Dialogs.showDiscardDialog(this, + (dialog, which) -> { + try { + saveAndFinish(); + } catch (IOException e) { + Toast.makeText(this, R.string.saving_assign_icons_error, Toast.LENGTH_SHORT).show(); + } + }, + (dialog, which) -> finish()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_assign_icons, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + discardAndFinish(); + } else if (itemId == R.id.action_save) { + try { + saveAndFinish(); + } catch (IOException e) { + Toast.makeText(this, R.string.saving_assign_icons_error, Toast.LENGTH_SHORT).show(); + } + } else { + return super.onOptionsItemSelected(item); + } + + return true; + } + + @Override + public void onAssignIconEntryClick(AssignIconEntry entry) { + List iconPacks = _iconPackManager.getIconPacks().stream() + .sorted(Comparator.comparing(IconPack::getName)) + .collect(Collectors.toList()); + + BottomSheetDialog dialog = IconPickerDialog.create(this, iconPacks, entry.getEntry().getIssuer(), false, new IconAdapter.Listener() { + @Override + public void onIconSelected(IconPack.Icon icon) { + entry.setNewIcon(icon); + } + + @Override + public void onCustomSelected() { } + }); + Dialogs.showSecureDialog(dialog); + } + + @Override + public void onSetPreloadView(View view) { + _preloadSizeProvider.setView(view); + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + discardAndFinish(); + } + } + + private class EntryIconPreloadProvider implements ListPreloader.PreloadModelProvider { + @NonNull + @Override + public List getPreloadItems(int position) { + VaultEntry entry = _entries.get(position).getEntry(); + if (entry.hasIcon()) { + return Collections.singletonList(entry); + } + return Collections.emptyList(); + } + + @Nullable + @Override + public RequestBuilder getPreloadRequestBuilder(@NonNull VaultEntry entry) { + RequestBuilder rb = Glide.with(AssignIconsActivity.this) + .load(entry.getIcon()); + return GlideHelper.setCommonOptions(rb, entry.getIcon().getType()); + } + } + + private class IconPreloadProvider implements ListPreloader.PreloadModelProvider { + @NonNull + @Override + public List getPreloadItems(int position) { + AssignIconEntry entry = _entries.get(position); + if (entry.getNewIcon() != null) { + return Collections.singletonList(entry.getNewIcon()); + } + return Collections.emptyList(); + } + + @Nullable + @Override + public RequestBuilder getPreloadRequestBuilder(@NonNull IconPack.Icon icon) { + RequestBuilder rb = Glide.with(AssignIconsActivity.this) + .load(icon.getFile()); + return GlideHelper.setCommonOptions(rb, icon.getIconType()); + } + } + + private class SpacesItemDecoration extends RecyclerView.ItemDecoration { + private final int _space; + + public SpacesItemDecoration(int dpSpace) { + + this._space = MetricsHelper.convertDpToPixels(AssignIconsActivity.this, dpSpace); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.left = _space; + outRect.right = _space; + outRect.bottom = _space; + + if (parent.getChildLayoutPosition(view) == 0) { + outRect.top = _space; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java new file mode 100644 index 0000000..a30c552 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java @@ -0,0 +1,402 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.text.InputType; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.PopupWindow; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.biometric.BiometricPrompt; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.KeyStoreHandle; +import com.beemdevelopment.aegis.crypto.KeyStoreHandleException; +import com.beemdevelopment.aegis.crypto.MasterKey; +import com.beemdevelopment.aegis.helpers.BiometricsHelper; +import com.beemdevelopment.aegis.helpers.EditTextHelper; +import com.beemdevelopment.aegis.helpers.MetricsHelper; +import com.beemdevelopment.aegis.helpers.UiThreadExecutor; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.PasswordSlotDecryptTask; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultFileCredentials; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.beemdevelopment.aegis.vault.slots.BiometricSlot; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; +import com.beemdevelopment.aegis.vault.slots.Slot; +import com.beemdevelopment.aegis.vault.slots.SlotException; +import com.beemdevelopment.aegis.vault.slots.SlotIntegrityException; +import com.beemdevelopment.aegis.vault.slots.SlotList; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.List; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; + +public class AuthActivity extends AegisActivity { + // Permission request codes + private static final int CODE_PERM_NOTIFICATIONS = 0; + + private EditText _textPassword; + + private VaultFile _vaultFile; + private SlotList _slots; + + private SecretKey _bioKey; + private BiometricSlot _bioSlot; + private BiometricPrompt _bioPrompt; + private Button _decryptButton; + + private int _failedUnlockAttempts; + + // the first time this activity is resumed after creation, it's possible to inhibit showing the + // biometric prompt by setting 'inhibitBioPrompt' to true through the intent + private boolean _inhibitBioPrompt; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_auth); + + TextInputLayout layoutStandard = findViewById(R.id.layout_standard); + TextInputLayout layoutNoAutofill = findViewById(R.id.layout_no_autofill); + EditText editStandard = findViewById(R.id.text_password); + EditText editNoAutofill = findViewById(R.id.text_password_no_autofill); + + if (_prefs.isPinKeyboardEnabled()) { + layoutStandard.setVisibility(View.GONE); + layoutNoAutofill.setVisibility(View.VISIBLE); + _textPassword = editNoAutofill; + } else { + layoutStandard.setVisibility(View.VISIBLE); + layoutNoAutofill.setVisibility(View.GONE); + _textPassword = editStandard; + } + + LinearLayout boxBiometricInfo = findViewById(R.id.box_biometric_info); + _decryptButton = findViewById(R.id.button_decrypt); + TextView biometricsButton = findViewById(R.id.button_biometrics); + + getOnBackPressedDispatcher().addCallback(this, new BackPressHandler()); + + _textPassword.setOnEditorActionListener((v, actionId, event) -> { + if ((event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) || (actionId == EditorInfo.IME_ACTION_DONE)) { + _decryptButton.performClick(); + } + return false; + }); + + Intent intent = getIntent(); + if (savedInstanceState == null) { + _inhibitBioPrompt = intent.getBooleanExtra("inhibitBioPrompt", false); + + // A persistent notification is shown to let the user know that the vault is unlocked. Permission + // to do so is required since API 33, so for existing users, we have to request permission here + // in order to be able to show the notification after unlock. + // + // NOTE: Disabled for now. See issue: #1047 + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + PermissionHelper.request(this, CODE_PERM_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS); + }*/ + } else { + _inhibitBioPrompt = savedInstanceState.getBoolean("inhibitBioPrompt", false); + } + + try { + _vaultFile = VaultRepository.readVaultFile(this); + } catch (VaultRepositoryException e) { + Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> { + getOnBackPressedDispatcher().onBackPressed(); + }); + return; + } + + // only show the biometric prompt if the api version is new enough, permission is granted, a scanner is found and a biometric slot is found + _slots = _vaultFile.getHeader().getSlots(); + if (_slots.has(BiometricSlot.class) && BiometricsHelper.isAvailable(this)) { + boolean invalidated = false; + + try { + // find a biometric slot with an id that matches an alias in the keystore + for (BiometricSlot slot : _slots.findAll(BiometricSlot.class)) { + String id = slot.getUUID().toString(); + KeyStoreHandle handle = new KeyStoreHandle(); + if (handle.containsKey(id)) { + SecretKey key = handle.getKey(id); + // if 'key' is null, it was permanently invalidated + if (key == null) { + invalidated = true; + continue; + } + + _bioSlot = slot; + _bioKey = key; + biometricsButton.setVisibility(View.VISIBLE); + invalidated = false; + break; + } + } + } catch (KeyStoreHandleException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.biometric_init_error, e); + } + + // display a help message if a matching invalidated keystore entry was found + if (invalidated) { + boxBiometricInfo.setVisibility(View.VISIBLE); + biometricsButton.setVisibility(View.GONE); + } + } + + _decryptButton.setOnClickListener(v -> { + InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(v.getWindowToken(), 0); + + char[] password = EditTextHelper.getEditTextChars(_textPassword); + List slots = _slots.findAll(PasswordSlot.class); + PasswordSlotDecryptTask.Params params = new PasswordSlotDecryptTask.Params(slots, password); + PasswordSlotDecryptTask task = new PasswordSlotDecryptTask(AuthActivity.this, new PasswordDerivationListener()); + task.execute(getLifecycle(), params); + + _decryptButton.setEnabled(false); + }); + + biometricsButton.setOnClickListener(v -> { + if (_prefs.isPasswordReminderNeeded()) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(getString(R.string.password_reminder_dialog_title)) + .setMessage(getString(R.string.password_reminder_dialog_message)) + .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, (dialog1, which) -> { + showBiometricPrompt(); + }) + .create()); + } else { + showBiometricPrompt(); + } + }); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean("inhibitBioPrompt", _inhibitBioPrompt); + } + + private void selectPassword() { + _textPassword.selectAll(); + + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0); + } + + @Override + public void onResume() { + super.onResume(); + + boolean remindPassword = _prefs.isPasswordReminderNeeded(); + if (_bioKey == null || remindPassword) { + focusPasswordField(); + } + + if (_bioKey != null && _bioPrompt == null && !_inhibitBioPrompt && !remindPassword) { + _bioPrompt = showBiometricPrompt(); + } + + _inhibitBioPrompt = false; + } + + @Override + public void onPause() { + if (!isChangingConfigurations() && _bioPrompt != null) { + _bioPrompt.cancelAuthentication(); + _bioPrompt = null; + } + + super.onPause(); + } + + @Override + public void onAttachedToWindow() { + if (_bioKey != null && _prefs.isPasswordReminderNeeded()) { + showPasswordReminder(); + } + } + + private void focusPasswordField() { + _textPassword.requestFocus(); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + } + + private void showPasswordReminder() { + View popupLayout = getLayoutInflater().inflate(R.layout.popup_password, null); + popupLayout.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED); + + PopupWindow popup = new PopupWindow(popupLayout, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + popup.setFocusable(false); + popup.setOutsideTouchable(true); + _textPassword.post(() -> { + if (isFinishing() || !_textPassword.isAttachedToWindow()) { + return; + } + + // calculating the actual height of the popup window does not seem possible + // adding 25dp seems to look good enough + int yoff = _textPassword.getHeight() + + popupLayout.getMeasuredHeight() + + MetricsHelper.convertDpToPixels(this, 25); + popup.showAsDropDown(_textPassword, 0, -yoff); + }); + _textPassword.postDelayed(popup::dismiss, 5000); + } + + public BiometricPrompt showBiometricPrompt() { + InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(_textPassword.getWindowToken(), 0); + + Cipher cipher; + try { + cipher = _bioSlot.createDecryptCipher(_bioKey); + } catch (SlotException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.biometric_init_error, e); + return null; + } + + BiometricPrompt.CryptoObject cryptoObj = new BiometricPrompt.CryptoObject(cipher); + BiometricPrompt prompt = new BiometricPrompt(this, new UiThreadExecutor(), new BiometricPromptListener()); + + BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.authentication)) + .setNegativeButtonText(getString(android.R.string.cancel)) + .setConfirmationRequired(false) + .build(); + prompt.authenticate(info, cryptoObj); + return prompt; + } + + private void finish(MasterKey key, boolean isSlotRepaired) { + VaultFileCredentials creds = new VaultFileCredentials(key, _slots); + + try { + _vaultManager.loadFrom(_vaultFile, creds); + if (isSlotRepaired) { + saveAndBackupVault(); + } + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.decryption_corrupt_error, e); + return; + } + + setResult(RESULT_OK); + finish(); + } + + private void onInvalidPassword() { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(AuthActivity.this, R.style.ThemeOverlay_Aegis_AlertDialog_Error) + .setTitle(getString(R.string.unlock_vault_error)) + .setMessage(getString(R.string.unlock_vault_error_description)) + .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, (dialog, which) -> selectPassword()) + .create()); + + _failedUnlockAttempts ++; + + if (_failedUnlockAttempts >= 3) { + _textPassword.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + } + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(true); + } + + @Override + public void handleOnBackPressed() { + // This breaks predictive back gestures, but it doesn't make sense + // to go back to MainActivity when cancelling auth + setResult(RESULT_CANCELED); + finishAffinity(); + } + } + + private class PasswordDerivationListener implements PasswordSlotDecryptTask.Callback { + @Override + public void onTaskFinished(PasswordSlotDecryptTask.Result result) { + if (result != null) { + // replace the old slot with the repaired one + if (result.isSlotRepaired()) { + _slots.replace(result.getSlot()); + } + + if (result.getSlot().getType() == Slot.TYPE_PASSWORD) { + _prefs.resetPasswordReminderTimestamp(); + } + + finish(result.getKey(), result.isSlotRepaired()); + } else { + _decryptButton.setEnabled(true); + + _auditLogRepository.addVaultUnlockFailedPasswordEvent(); + onInvalidPassword(); + } + } + } + + private class BiometricPromptListener extends BiometricPrompt.AuthenticationCallback { + @Override + public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) { + super.onAuthenticationError(errorCode, errString); + _bioPrompt = null; + + if (!BiometricsHelper.isCanceled(errorCode)) { + _auditLogRepository.addVaultUnlockFailedBiometricsEvent(); + Toast.makeText(AuthActivity.this, errString, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + _bioPrompt = null; + + MasterKey key; + BiometricSlot slot = _slots.find(BiometricSlot.class); + + try { + key = slot.getKey(result.getCryptoObject().getCipher()); + } catch (SlotException | SlotIntegrityException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(AuthActivity.this, R.string.biometric_decrypt_error, e); + return; + } + + finish(key, false); + } + + @Override + public void onAuthenticationFailed() { + super.onAuthenticationFailed(); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java new file mode 100644 index 0000000..3601186 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java @@ -0,0 +1,1025 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextWatcher; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; + +import com.amulyakhare.textdrawable.TextDrawable; +import com.avito.android.krop.KropView; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.encoding.Hex; +import com.beemdevelopment.aegis.helpers.AnimationsHelper; +import com.beemdevelopment.aegis.helpers.BitmapHelper; +import com.beemdevelopment.aegis.helpers.DropdownHelper; +import com.beemdevelopment.aegis.helpers.EditTextHelper; +import com.beemdevelopment.aegis.helpers.SafHelper; +import com.beemdevelopment.aegis.helpers.SimpleAnimationEndListener; +import com.beemdevelopment.aegis.helpers.SimpleTextWatcher; +import com.beemdevelopment.aegis.helpers.TextDrawableHelper; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.icons.IconType; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.MotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.otp.YandexInfo; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog; +import com.beemdevelopment.aegis.ui.glide.GlideHelper; +import com.beemdevelopment.aegis.ui.models.VaultGroupModel; +import com.beemdevelopment.aegis.ui.tasks.ImportFileTask; +import com.beemdevelopment.aegis.ui.views.IconAdapter; +import com.beemdevelopment.aegis.util.Cloner; +import com.beemdevelopment.aegis.util.IOUtils; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.imageview.ShapeableImageView; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +public class EditEntryActivity extends AegisActivity { + private boolean _isNew = false; + private boolean _isManual = false; + private VaultEntry _origEntry; + private Collection _groups; + private boolean _hasCustomIcon = false; + // keep track of icon changes separately as the generated jpeg's are not deterministic + private boolean _hasChangedIcon = false; + private IconPack.Icon _selectedIcon; + private String _pickedMimeType; + private ShapeableImageView _iconView; + private ImageView _saveImageButton; + + private TextInputEditText _textName; + private TextInputEditText _textIssuer; + private TextInputLayout _textGroupLayout; + private TextInputEditText _textGroup; + private TextInputEditText _textPeriodCounter; + private TextInputLayout _textPeriodCounterLayout; + private TextInputEditText _textDigits; + private TextInputLayout _textDigitsLayout; + private TextInputEditText _textSecret; + private TextInputEditText _textPin; + private LinearLayout _textPinLayout; + private TextInputEditText _textUsageCount; + private TextInputEditText _textNote; + private TextView _textLastUsed; + + private AutoCompleteTextView _dropdownType; + private AutoCompleteTextView _dropdownAlgo; + private TextInputLayout _dropdownAlgoLayout; + private List _selectedGroups = new ArrayList<>(); + + private KropView _kropView; + + private RelativeLayout _advancedSettingsHeader; + private LinearLayout _advancedSettingsLayout; + + private BackPressHandler _backPressHandler; + private IconBackPressHandler _iconBackPressHandler; + + private final ActivityResultLauncher pickImageResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + Intent data = activityResult.getData(); + if (activityResult.getResultCode() != RESULT_OK || data == null || data.getData() == null) { + return; + } + _pickedMimeType = SafHelper.getMimeType(this, data.getData()); + if (_pickedMimeType != null && _pickedMimeType.equals(IconType.SVG.toMimeType())) { + ImportFileTask.Params params = new ImportFileTask.Params(data.getData(), "icon", null); + ImportFileTask task = new ImportFileTask(this, result -> { + if (result.getError() == null) { + CustomSvgIcon icon = new CustomSvgIcon(result.getFile()); + selectIcon(icon); + } else { + Dialogs.showErrorDialog(this, R.string.reading_file_error, result.getError()); + } + }); + task.execute(getLifecycle(), params); + } else { + startEditingIcon(data.getData()); + } + }); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_edit_entry); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + + _groups = _vaultManager.getVault().getGroups(); + + ActionBar bar = getSupportActionBar(); + if (bar != null) { + bar.setHomeAsUpIndicator(R.drawable.ic_outline_close_24); + bar.setDisplayHomeAsUpEnabled(true); + } + + _backPressHandler = new BackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + _iconBackPressHandler = new IconBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _iconBackPressHandler); + + // retrieve info from the calling activity + Intent intent = getIntent(); + UUID entryUUID = (UUID) intent.getSerializableExtra("entryUUID"); + if (entryUUID != null) { + _origEntry = _vaultManager.getVault().getEntryByUUID(entryUUID); + } else { + _origEntry = (VaultEntry) intent.getSerializableExtra("newEntry"); + _isManual = intent.getBooleanExtra("isManual", false); + _isNew = true; + setTitle(R.string.add_new_entry); + } + + // set up fields + _iconView = findViewById(R.id.profile_drawable); + _kropView = findViewById(R.id.krop_view); + _saveImageButton = findViewById(R.id.iv_saveImage); + _textName = findViewById(R.id.text_name); + _textIssuer = findViewById(R.id.text_issuer); + _textGroup = findViewById(R.id.text_group); + _textGroupLayout = findViewById(R.id.text_group_layout); + _textPeriodCounter = findViewById(R.id.text_period_counter); + _textPeriodCounterLayout = findViewById(R.id.text_period_counter_layout); + _textDigits = findViewById(R.id.text_digits); + _textDigitsLayout = findViewById(R.id.text_digits_layout); + _textSecret = findViewById(R.id.text_secret); + _textPin = findViewById(R.id.text_pin); + _textPinLayout = findViewById(R.id.layout_pin); + _textUsageCount = findViewById(R.id.text_usage_count); + _textNote = findViewById(R.id.text_note); + _textLastUsed = findViewById(R.id.text_last_used); + _dropdownType = findViewById(R.id.dropdown_type); + DropdownHelper.fillDropdown(this, _dropdownType, R.array.otp_types_array); + _dropdownAlgoLayout = findViewById(R.id.dropdown_algo_layout); + _dropdownAlgo = findViewById(R.id.dropdown_algo); + DropdownHelper.fillDropdown(this, _dropdownAlgo, R.array.otp_algo_array); + + // if this is NOT a manually entered entry, move the "Secret" field from basic to advanced settings + if (!_isNew || !_isManual) { + int secretIndex = 0; + LinearLayout layoutSecret = findViewById(R.id.layout_secret); + LinearLayout layoutBasic = findViewById(R.id.layout_basic); + LinearLayout layoutAdvanced = findViewById(R.id.layout_advanced); + layoutBasic.removeView(layoutSecret); + if (!_isNew) { + secretIndex = 1; + layoutBasic.removeView(_textPinLayout); + layoutAdvanced.addView(_textPinLayout, 0); + ((LinearLayout.LayoutParams) _textPinLayout.getLayoutParams()).topMargin = 0; + } else { + ((LinearLayout.LayoutParams) layoutSecret.getLayoutParams()).topMargin = 0; + } + layoutAdvanced.addView(layoutSecret, secretIndex); + + if (_isNew && !_isManual) { + setViewEnabled(layoutAdvanced, false); + } + } else { + LinearLayout layoutTypeAlgo = findViewById(R.id.layout_type_algo); + ((LinearLayout.LayoutParams) layoutTypeAlgo.getLayoutParams()).topMargin = 0; + } + + _advancedSettingsHeader = findViewById(R.id.accordian_header); + _advancedSettingsHeader.setOnClickListener(v -> openAdvancedSettings()); + _advancedSettingsLayout = findViewById(R.id.layout_advanced); + + // fill the fields with values if possible + GlideHelper.loadEntryIcon(Glide.with(this), _origEntry, _iconView); + if (_origEntry.hasIcon()) { + _hasCustomIcon = true; + } + + _textName.setText(_origEntry.getName()); + _textIssuer.setText(_origEntry.getIssuer()); + _textNote.setText(_origEntry.getNote()); + + OtpInfo info = _origEntry.getInfo(); + + if (info instanceof TotpInfo) { + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(Integer.toString(((TotpInfo) info).getPeriod())); + } else if (info instanceof HotpInfo) { + _textPeriodCounterLayout.setHint(R.string.counter); + _textPeriodCounter.setText(Long.toString(((HotpInfo) info).getCounter())); + } else { + throw new RuntimeException(String.format("Unsupported OtpInfo type: %s", info.getClass())); + } + _textDigits.setText(Integer.toString(info.getDigits())); + + byte[] secretBytes = _origEntry.getInfo().getSecret(); + if (secretBytes != null) { + String secretString = (info instanceof MotpInfo) ? Hex.encode(secretBytes) : Base32.encode(secretBytes); + _textSecret.setText(secretString); + } + + _dropdownType.setText(_origEntry.getInfo().getType(), false); + _dropdownAlgo.setText(_origEntry.getInfo().getAlgorithm(false), false); + + if (info instanceof YandexInfo) { + _textPin.setText(((YandexInfo) info).getPin()); + } else if (info instanceof MotpInfo) { + _textPin.setText(((MotpInfo) info).getPin()); + } + + updateAdvancedFieldStatus(_origEntry.getInfo().getTypeId()); + updatePinFieldVisibility(_origEntry.getInfo().getTypeId()); + + Set groups = _origEntry.getGroups(); + if (groups.isEmpty()) { + _textGroup.setText(getString(R.string.no_group)); + } else { + String text = groups.stream().map(uuid -> { + VaultGroup group = _vaultManager.getVault().getGroupByUUID(uuid); + return group.getName(); + }) + .collect(Collectors.joining(", ")); + _selectedGroups.addAll(groups); + _textGroup.setText(text); + } + + // Update the icon if the issuer or name has changed + _textIssuer.addTextChangedListener(_nameChangeListener); + _textName.addTextChangedListener(_nameChangeListener); + + // Register listeners to trigger validation + _textIssuer.addTextChangedListener(_validationListener); + _textGroup.addTextChangedListener(_validationListener); + _textName.addTextChangedListener(_validationListener); + _textNote.addTextChangedListener(_validationListener); + _textSecret.addTextChangedListener(_validationListener); + _dropdownType.addTextChangedListener(_validationListener); + _dropdownAlgo.addTextChangedListener(_validationListener); + _textPeriodCounter.addTextChangedListener(_validationListener); + _textDigits.addTextChangedListener(_validationListener); + _textPin.addTextChangedListener(_validationListener); + + // show/hide period and counter fields on type change + _dropdownType.setOnItemClickListener((parent, view, position, id) -> { + String type = _dropdownType.getText().toString().toLowerCase(Locale.ROOT); + switch (type) { + case SteamInfo.ID: + _dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD)); + _textDigits.setText(String.valueOf(SteamInfo.DIGITS)); + break; + case TotpInfo.ID: + _dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD)); + _textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS)); + break; + case HotpInfo.ID: + _dropdownAlgo.setText(OtpInfo.DEFAULT_ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.counter); + _textPeriodCounter.setText(String.valueOf(HotpInfo.DEFAULT_COUNTER)); + _textDigits.setText(String.valueOf(OtpInfo.DEFAULT_DIGITS)); + break; + case YandexInfo.ID: + _dropdownAlgo.setText(YandexInfo.DEFAULT_ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(String.valueOf(TotpInfo.DEFAULT_PERIOD)); + _textDigits.setText(String.valueOf(YandexInfo.DIGITS)); + break; + case MotpInfo.ID: + _dropdownAlgo.setText(MotpInfo.ALGORITHM, false); + _textPeriodCounterLayout.setHint(R.string.period_hint); + _textPeriodCounter.setText(String.valueOf(MotpInfo.PERIOD)); + _textDigits.setText(String.valueOf(MotpInfo.DIGITS)); + break; + default: + throw new RuntimeException(String.format("Unsupported OTP type: %s", type)); + } + + updateAdvancedFieldStatus(type); + updatePinFieldVisibility(type); + }); + + _iconView.setOnClickListener(v -> { + startIconSelection(); + }); + + _textGroup.setShowSoftInputOnFocus(false); + _textGroup.setOnClickListener(v -> showGroupSelectionDialog()); + _textGroup.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + showGroupSelectionDialog(); + } + }); + + _textGroupLayout.setOnClickListener(v -> { + showGroupSelectionDialog(); + }); + + _textUsageCount.setText(_prefs.getUsageCount(entryUUID).toString()); + setLastUsedTimestamp(_prefs.getLastUsedTimestamp(entryUUID)); + } + + private void showGroupSelectionDialog() { + BottomSheetDialog dialog = new BottomSheetDialog(this); + View view = getLayoutInflater().inflate(R.layout.dialog_select_groups, null); + dialog.setContentView(view); + + ChipGroup chipGroup = view.findViewById(R.id.groupChipGroup); + TextView addGroupInfo = view.findViewById(R.id.addGroupInfo); + LinearLayout addGroup = view.findViewById(R.id.addGroup); + Button clearButton = view.findViewById(R.id.btnClear); + Button saveButton = view.findViewById(R.id.btnSave); + + chipGroup.removeAllViews(); + addGroupInfo.setVisibility(View.VISIBLE); + addGroup.setVisibility(View.VISIBLE); + + for (VaultGroup group : _groups) { + addChipTo(chipGroup, new VaultGroupModel(group), false); + } + + addGroup.setOnClickListener(v1 -> { + Dialogs.TextInputListener onAddGroup = text -> { + String groupName = new String(text).trim(); + if (!groupName.isEmpty()) { + VaultGroup group = _vaultManager.getVault().findGroupByName(groupName); + if (group == null) { + group = new VaultGroup(groupName); + _vaultManager.getVault().addGroup(group); + } + + _selectedGroups.add(group.getUUID()); + addChipTo(chipGroup, new VaultGroupModel(group), true); + } + }; + + Dialogs.showTextInputDialog(EditEntryActivity.this, R.string.set_group, R.string.group_name_hint, onAddGroup); + }); + + saveButton.setOnClickListener(v1 -> { + if(getCheckedUUID(chipGroup).isEmpty()) { + _selectedGroups.clear(); + _textGroup.setText(getString(R.string.no_group)); + } else { + _selectedGroups.clear(); + _selectedGroups.addAll(getCheckedUUID(chipGroup)); + _textGroup.setText(getCheckedNames(chipGroup)); + } + dialog.dismiss(); + }); + + clearButton.setOnClickListener(v1 -> { + chipGroup.clearCheck(); + }); + + Dialogs.showSecureDialog(dialog); + } + + private void addChipTo(ChipGroup chipGroup, VaultGroupModel group, Boolean isNew) { + Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false); + chip.setText(group.getName()); + chip.setCheckable(true); + + chip.setChecked((!_selectedGroups.isEmpty() && _selectedGroups.contains(group.getUUID())) || isNew); + chip.setCheckedIconVisible(true); + chip.setTag(group); + chipGroup.addView(chip); + } + + private static Set getCheckedUUID(ChipGroup chipGroup) { + return chipGroup.getCheckedChipIds().stream() + .map(i -> { + Chip chip = chipGroup.findViewById(i); + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getUUID(); + }) + .collect(Collectors.toSet()); + } + + private static String getCheckedNames(ChipGroup chipGroup) { + return chipGroup.getCheckedChipIds().stream() + .map(i -> { + Chip chip = chipGroup.findViewById(i); + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getName(); + }) + .collect(Collectors.joining(", ")); + } + + private void updateAdvancedFieldStatus(String otpType) { + boolean enabled = !otpType.equals(SteamInfo.ID) && !otpType.equals(YandexInfo.ID) + && !otpType.equals(MotpInfo.ID) && (!_isNew || _isManual); + _textDigitsLayout.setEnabled(enabled); + _textPeriodCounterLayout.setEnabled(enabled); + _dropdownAlgoLayout.setEnabled(enabled); + } + + private void updatePinFieldVisibility(String otpType) { + boolean visible = otpType.equals(YandexInfo.ID) || otpType.equals(MotpInfo.ID); + _textPinLayout.setVisibility(visible ? View.VISIBLE : View.GONE); + _textPin.setHint(otpType.equals(MotpInfo.ID) ? R.string.motp_pin : R.string.yandex_pin); + } + + private void openAdvancedSettings() { + Animation fadeOut = new AlphaAnimation(1, 0); + fadeOut.setInterpolator(new AccelerateInterpolator()); + fadeOut.setDuration((long) (220 * AnimationsHelper.Scale.ANIMATOR.getValue(this))); + _advancedSettingsHeader.startAnimation(fadeOut); + + fadeOut.setAnimationListener(new SimpleAnimationEndListener((a) -> { + _advancedSettingsHeader.setVisibility(View.GONE); + _advancedSettingsLayout.setVisibility(View.VISIBLE); + _advancedSettingsLayout.animate() + .setInterpolator(new AccelerateInterpolator()) + .setDuration((long) (250 * AnimationsHelper.Scale.ANIMATOR.getValue(this))) + .alpha(1); + })); + } + + private boolean hasUnsavedChanges(VaultEntry newEntry) { + return _hasChangedIcon || !_origEntry.equals(newEntry); + } + + private void discardAndFinish() { + AtomicReference msg = new AtomicReference<>(); + AtomicReference entry = new AtomicReference<>(); + try { + entry.set(parseEntry()); + } catch (ParseException e) { + msg.set(e.getMessage()); + } + + if (!hasUnsavedChanges(entry.get())) { + finish(); + return; + } + + // ask for confirmation if the entry has been changed + Dialogs.showDiscardDialog(EditEntryActivity.this, + (dialog, which) -> { + // if the entry couldn't be parsed, we show an error dialog + if (msg.get() != null) { + onSaveError(msg.get()); + return; + } + + addAndFinish(entry.get()); + }, + (dialog, which) -> finish() + ); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + discardAndFinish(); + } else if (itemId == R.id.action_save) { + onSave(); + } else if (itemId == R.id.action_delete) { + Dialogs.showDeleteEntriesDialog(this, Collections.singletonList(_origEntry), (dialog, which) -> { + deleteAndFinish(_origEntry); + }); + } else if (itemId == R.id.action_edit_icon) { + startIconSelection(); + } else if (itemId == R.id.action_reset_usage_count) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this) + .setTitle(R.string.action_reset_usage_count) + .setMessage(R.string.action_reset_usage_count_dialog) + .setPositiveButton(android.R.string.yes, (dialog, which) -> resetUsageCount()) + .setNegativeButton(android.R.string.no, null) + .create()); + } else if (itemId == R.id.action_default_icon) { + TextDrawable drawable = TextDrawableHelper.generate(_origEntry.getIssuer(), _origEntry.getName(), _iconView); + _iconView.setImageDrawable(drawable); + + _selectedIcon = null; + _hasCustomIcon = false; + _hasChangedIcon = true; + } else { + return super.onOptionsItemSelected(item); + } + + return true; + } + + private void startImageSelectionActivity() { + Intent galleryIntent = new Intent(Intent.ACTION_PICK); + galleryIntent.setDataAndType(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); + + Intent fileIntent = new Intent(Intent.ACTION_GET_CONTENT); + fileIntent.setType("image/*"); + + Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.select_icon)); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { fileIntent }); + _vaultManager.fireIntentLauncher(this, chooserIntent, pickImageResultLauncher); + } + + private void resetUsageCount() { + _prefs.resetUsageCount(_origEntry.getUUID()); + _textUsageCount.setText("0"); + } + + private void startIconSelection() { + List iconPacks = _iconPackManager.getIconPacks().stream() + .sorted(Comparator.comparing(IconPack::getName)) + .collect(Collectors.toList()); + if (iconPacks.size() == 0) { + startImageSelectionActivity(); + return; + } + + BottomSheetDialog dialog = IconPickerDialog.create(this, iconPacks, _textIssuer.getText().toString(), true, new IconAdapter.Listener() { + @Override + public void onIconSelected(IconPack.Icon icon) { + selectIcon(icon); + } + + @Override + public void onCustomSelected() { + startImageSelectionActivity(); + } + }); + Dialogs.showSecureDialog(dialog); + } + + private void selectIcon(IconPack.Icon icon) { + _selectedIcon = icon; + _hasCustomIcon = true; + _hasChangedIcon = true; + + GlideHelper.loadIcon(Glide.with(EditEntryActivity.this), icon, _iconView); + } + + private void startEditingIcon(Uri data) { + Glide.with(this) + .asBitmap() + .load(data) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(false) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + _kropView.setBitmap(resource); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + + } + }); + _iconView.setVisibility(View.GONE); + _kropView.setVisibility(View.VISIBLE); + + _saveImageButton.setOnClickListener(v -> { + stopEditingIcon(true); + }); + + _iconBackPressHandler.setEnabled(true); + } + + private void stopEditingIcon(boolean save) { + if (save && _selectedIcon == null) { + _iconView.setImageBitmap(_kropView.getCroppedBitmap()); + } + _iconView.setVisibility(View.VISIBLE); + _kropView.setVisibility(View.GONE); + + _hasCustomIcon = _hasCustomIcon || save; + _hasChangedIcon = save; + _iconBackPressHandler.setEnabled(false); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_edit, menu); + if (_isNew) { + menu.findItem(R.id.action_delete).setVisible(false); + } + if (!_hasCustomIcon) { + menu.findItem(R.id.action_default_icon).setVisible(false); + } + + return true; + } + + private void addAndFinish(VaultEntry entry) { + // It's possible that the new entry was already added to the vault, but writing the + // vault to disk failed, causing the user to tap 'Save' again. Calling addEntry + // again would cause a crash in that case, so the isEntryDuplicate check prevents + // that. + VaultRepository vault = _vaultManager.getVault(); + if (_isNew && !vault.isEntryDuplicate(entry)) { + vault.addEntry(entry); + } else { + vault.replaceEntry(entry); + } + + saveAndFinish(entry, false); + } + + private void setLastUsedTimestamp(long timestamp) { + String readableDate = getString(R.string.last_used_never); + if (timestamp != 0) { + DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM, Locale.getDefault()); + readableDate = dateFormat.format(new Date(timestamp)); + } + + _textLastUsed.setText(String.format("%s: %s", getString(R.string.last_used), readableDate)); + } + + private void deleteAndFinish(VaultEntry entry) { + _vaultManager.getVault().removeEntry(entry); + saveAndFinish(entry, true); + } + + private void saveAndFinish(VaultEntry entry, boolean delete) { + Intent intent = new Intent(); + intent.putExtra("entryUUID", entry.getUUID()); + intent.putExtra("delete", delete); + + if (saveAndBackupVault()) { + setResult(RESULT_OK, intent); + finish(); + } + } + + private int parsePeriod() throws ParseException { + try { + return Integer.parseInt(_textPeriodCounter.getText().toString()); + } catch (NumberFormatException e) { + throw new ParseException("Period is not an integer."); + } + } + + private VaultEntry parseEntry() throws ParseException { + if (_textSecret.length() == 0) { + throw new ParseException("Secret is a required field."); + } + + String type = _dropdownType.getText().toString(); + String algo = _dropdownAlgo.getText().toString(); + String lowerCasedType = type.toLowerCase(Locale.ROOT); + + if (lowerCasedType.equals(YandexInfo.ID) || lowerCasedType.equals(MotpInfo.ID)) { + int pinLength = _textPin.length(); + if (pinLength < 4) { + throw new ParseException("PIN is a required field. Must have a minimum length of 4 digits."); + } + if (pinLength != 4 && lowerCasedType.equals(MotpInfo.ID)) { + throw new ParseException("PIN must have a length of 4 digits."); + } + } + + int digits; + try { + digits = Integer.parseInt(_textDigits.getText().toString()); + } catch (NumberFormatException e) { + throw new ParseException("Digits is not an integer."); + } + + byte[] secret; + try { + String secretString = new String(EditTextHelper.getEditTextChars(_textSecret)); + + secret = (lowerCasedType.equals(MotpInfo.ID)) ? + Hex.decode(secretString) : GoogleAuthInfo.parseSecret(secretString); + + if (secret.length == 0) { + throw new ParseException("Secret cannot be empty"); + } + } catch (EncodingException e) { + String exceptionMessage = (lowerCasedType.equals(MotpInfo.ID)) ? + "Secret is not valid hexadecimal" : "Secret is not valid base32."; + + throw new ParseException(exceptionMessage); + } + + OtpInfo info; + try { + switch (type.toLowerCase(Locale.ROOT)) { + case TotpInfo.ID: + info = new TotpInfo(secret, algo, digits, parsePeriod()); + break; + case SteamInfo.ID: + info = new SteamInfo(secret, algo, digits, parsePeriod()); + break; + case HotpInfo.ID: + long counter; + try { + counter = Long.parseLong(_textPeriodCounter.getText().toString()); + } catch (NumberFormatException e) { + throw new ParseException("Counter is not an integer."); + } + info = new HotpInfo(secret, algo, digits, counter); + break; + case YandexInfo.ID: + info = new YandexInfo(secret, _textPin.getText().toString()); + break; + case MotpInfo.ID: + info = new MotpInfo(secret, _textPin.getText().toString()); + break; + default: + throw new RuntimeException(String.format("Unsupported OTP type: %s", type)); + } + + info.setDigits(digits); + info.setAlgorithm(algo); + } catch (OtpInfoException e) { + throw new ParseException("The entered info is incorrect: " + e.getMessage()); + } + + VaultEntry entry = Cloner.clone(_origEntry); + entry.setInfo(info); + entry.setIssuer(_textIssuer.getText().toString()); + entry.setName(_textName.getText().toString()); + entry.setNote(_textNote.getText().toString()); + + if (_selectedGroups.isEmpty()) { + entry.setGroups(new HashSet<>()); + } else { + entry.setGroups(new HashSet<>(_selectedGroups)); + } + + if (_hasChangedIcon) { + if (_hasCustomIcon) { + VaultEntryIcon icon; + if (_selectedIcon == null) { + Bitmap bitmap = ((BitmapDrawable) _iconView.getDrawable()).getBitmap(); + IconType iconType = _pickedMimeType == null + ? IconType.INVALID : IconType.fromMimeType(_pickedMimeType); + if (iconType == IconType.INVALID) { + iconType = bitmap.hasAlpha() ? IconType.PNG : IconType.JPEG; + } + icon = BitmapHelper.toVaultEntryIcon(bitmap, iconType); + } else { + byte[] iconBytes; + try (FileInputStream inStream = new FileInputStream(_selectedIcon.getFile())){ + iconBytes = IOUtils.readFile(inStream); + } catch (IOException e) { + throw new ParseException(e.getMessage()); + } + icon = new VaultEntryIcon(iconBytes, _selectedIcon.getIconType()); + } + + entry.setIcon(icon); + } else { + entry.setIcon(null); + } + } + + return entry; + } + + private void onSaveError(String msg) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Error) + .setTitle(getString(R.string.saving_profile_error)) + .setMessage(msg) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, null) + .create()); + } + + private boolean onSave() { + if (_iconBackPressHandler.isEnabled()) { + stopEditingIcon(true); + } + + VaultEntry entry; + try { + entry = parseEntry(); + } catch (ParseException e) { + onSaveError(e.getMessage()); + return false; + } + + if (_isNew) { + for (VaultEntry existing : _vaultManager.getVault().getEntries()) { + if (entry.hasSameNameAndIssuer(existing)) { + showDuplicateBottomSheet(entry); + return false; + } + } + } + + addAndFinish(entry); + return true; + } + + private void showDuplicateBottomSheet(VaultEntry newEntry) { + BottomSheetDialog dialog = new BottomSheetDialog(this); + View view = getLayoutInflater().inflate(R.layout.dialog_duplicate_entry, null); + dialog.setContentView(view); + + dialog.setCancelable(false); + + View overwrite = view.findViewById(R.id.overwrite_entry); + View addSuffix = view.findViewById(R.id.create_new_entry); + View cancel = view.findViewById(R.id.cancel_save); + + TextView suffixSubtext = view.findViewById(R.id.duplicate_suffix_subtitle); + + String baseName = newEntry.getName(); + Set existingNames = new HashSet<>(); + for (VaultEntry e : _vaultManager.getVault().getEntries()) { + if (e.getIssuer().equals(newEntry.getIssuer())) { + existingNames.add(e.getName()); + } + } + + int counter = 2; + String newName; + do { + newName = baseName + " #" + counter++; + } while (existingNames.contains(newName)); + + suffixSubtext.setText(getString(R.string.dialog_duplicate_entry_suffix_subtitle, newName)); + + overwrite.setOnClickListener(v -> { + List duplicates = new ArrayList<>(); + for (VaultEntry existing : _vaultManager.getVault().getEntries()) { + if (existing.hasSameNameAndIssuer(newEntry)) { + duplicates.add(existing); + } + } + + Resources res = getResources(); + String message = res.getQuantityString( + R.plurals.dialog_duplicate_entry_overwrite_dialog_message, + duplicates.size(), + duplicates.size(), + newEntry.getIssuer(), + newEntry.getName() + ); + + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_duplicate_entry_overwrite_dialog_title) + .setMessage(message) + .setPositiveButton(R.string.action_delete, (d, which) -> { + for (VaultEntry dup : duplicates) { + _vaultManager.getVault().removeEntry(dup); + } + + dialog.dismiss(); + addAndFinish(newEntry); + }) + .setNegativeButton(android.R.string.no, null) + .show(); + }); + + String finalNewName = newName; + addSuffix.setOnClickListener(v -> { + newEntry.setName(finalNewName); + dialog.dismiss(); + addAndFinish(newEntry); + }); + + cancel.setOnClickListener(v -> dialog.dismiss()); + + Dialogs.showSecureDialog(dialog); + } + + private static void setViewEnabled(View view, boolean enabled) { + view.setEnabled(enabled); + + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + setViewEnabled(group.getChildAt(i), enabled); + } + } + } + + private final TextWatcher _validationListener = new SimpleTextWatcher((s) -> { + updateBackPressHandlerState(); + }); + + private final TextWatcher _nameChangeListener = new SimpleTextWatcher((s) -> { + if (!_hasCustomIcon) { + TextDrawable drawable = TextDrawableHelper.generate(_textIssuer.getText().toString(), _textName.getText().toString(), _iconView); + _iconView.setImageDrawable(drawable); + } + }); + + private void updateBackPressHandlerState() { + VaultEntry entry = null; + try { + entry = parseEntry(); + } catch (ParseException ignored) { + + } + + boolean backEnabled = hasUnsavedChanges(entry); + _backPressHandler.setEnabled(backEnabled); + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + discardAndFinish(); + } + } + + private class IconBackPressHandler extends OnBackPressedCallback { + public IconBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + stopEditingIcon(false); + } + } + + private static class ParseException extends Exception { + public ParseException(String message) { + super(message); + } + } + + private static class CustomSvgIcon extends IconPack.Icon { + private final File _file; + + protected CustomSvgIcon(File file) { + super(file.getAbsolutePath(), null, null, null); + _file = file; + } + + @Nullable + @Override + public File getFile() { + return _file; + } + + @Override + public IconType getIconType() { + return IconType.SVG; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ExitActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ExitActivity.java new file mode 100644 index 0000000..b5b3732 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ExitActivity.java @@ -0,0 +1,26 @@ +package com.beemdevelopment.aegis.ui; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +public class ExitActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + finishAndRemoveTask(); + } + + public static void exitAppAndRemoveFromRecents(Context context) { + Intent intent = new Intent(context, ExitActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | + Intent.FLAG_ACTIVITY_CLEAR_TASK | + Intent.FLAG_ACTIVITY_NO_ANIMATION | + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + + context.startActivity(intent); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java new file mode 100644 index 0000000..4c29dea --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java @@ -0,0 +1,230 @@ +package com.beemdevelopment.aegis.ui; + +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.views.GroupAdapter; +import com.beemdevelopment.aegis.util.Cloner; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public class GroupManagerActivity extends AegisActivity implements GroupAdapter.Listener { + private GroupAdapter _adapter; + private HashSet _removedGroups; + private RecyclerView _groupsView; + private View _emptyStateView; + private BackPressHandler _backPressHandler; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_groups); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + _backPressHandler = new BackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + + _removedGroups = new HashSet<>(); + if (savedInstanceState != null) { + List removedGroups = savedInstanceState.getStringArrayList("removedGroups"); + if (removedGroups != null) { + for (String uuid : removedGroups) { + _removedGroups.add(UUID.fromString(uuid)); + } + } + } + + ItemTouchHelper touchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() { + @Override + public int getMovementFlags( + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder) { + + return makeMovementFlags(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) { + int draggedItemIndex = viewHolder.getBindingAdapterPosition(); + int targetIndex = target.getBindingAdapterPosition(); + + _adapter.onItemMove(draggedItemIndex, targetIndex); + + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { } + }); + + _adapter = new GroupAdapter(this); + _groupsView = findViewById(R.id.list_groups); + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + _groupsView.setLayoutManager(layoutManager); + _groupsView.setAdapter(_adapter); + _groupsView.setNestedScrollingEnabled(false); + touchHelper.attachToRecyclerView(_groupsView); + + for (VaultGroup group : _vaultManager.getVault().getGroups()) { + if (!_removedGroups.contains(group.getUUID())) { + _adapter.addGroup(group); + } + } + + _emptyStateView = findViewById(R.id.vEmptyList); + updateEmptyState(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + ArrayList removed = new ArrayList<>(); + for (UUID uuid : _removedGroups) { + removed.add(uuid.toString()); + } + + outState.putStringArrayList("removedGroups", removed); + } + + @Override + public void onEditGroup(VaultGroup group) { + Dialogs.TextInputListener onEditGroup = text -> { + String newGroupName = new String(text).trim(); + if (!newGroupName.isEmpty()) { + VaultGroup newGroup = Cloner.clone(group); + newGroup.setName(newGroupName); + _adapter.replaceGroup(group.getUUID(), newGroup); + _backPressHandler.setEnabled(true); + } + }; + + Dialogs.showTextInputDialog(GroupManagerActivity.this, R.string.rename_group, R.string.group_name_hint, onEditGroup, group.getName()); + } + + @Override + public void onRemoveGroup(VaultGroup group) { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.remove_group) + .setMessage(R.string.remove_group_description) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + _removedGroups.add(group.getUUID()); + _adapter.removeGroup(group); + _backPressHandler.setEnabled(true); + updateEmptyState(); + }) + .setNegativeButton(android.R.string.no, null) + .create()); + } + + public void onRemoveUnusedGroups() { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.remove_unused_groups) + .setMessage(R.string.remove_unused_groups_description) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + Set unusedGroups = new HashSet<>(_vaultManager.getVault().getGroups()); + unusedGroups.removeAll(_vaultManager.getVault().getUsedGroups()); + + for (VaultGroup group : unusedGroups) { + _removedGroups.add(group.getUUID()); + _adapter.removeGroup(group); + } + _backPressHandler.setEnabled(true); + updateEmptyState(); + }) + .setNegativeButton(android.R.string.no, null) + .create()); + } + + private void saveAndFinish() { + if (!_removedGroups.isEmpty()) { + for (UUID uuid : _removedGroups) { + _vaultManager.getVault().removeGroup(uuid); + } + } + + _vaultManager.getVault().replaceGroups(_adapter.getGroups()); + saveAndBackupVault(); + + finish(); + } + + private void discardAndFinish() { + if (_removedGroups.isEmpty()) { + finish(); + return; + } + + Dialogs.showDiscardDialog(this, + (dialog, which) -> saveAndFinish(), + (dialog, which) -> finish()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_groups, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + discardAndFinish(); + } else if (itemId == R.id.action_save) { + saveAndFinish(); + } else if (itemId == R.id.action_delete_unused_groups) { + onRemoveUnusedGroups(); + } else { + return super.onOptionsItemSelected(item); + } + + return true; + } + + private void updateEmptyState() { + if (_adapter.getItemCount() > 0) { + _groupsView.setVisibility(View.VISIBLE); + _emptyStateView.setVisibility(View.GONE); + } else { + _groupsView.setVisibility(View.GONE); + _emptyStateView.setVisibility(View.VISIBLE); + } + } + + private class BackPressHandler extends OnBackPressedCallback { + public BackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + discardAndFinish(); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java new file mode 100644 index 0000000..da04bc3 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java @@ -0,0 +1,418 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.BitmapHelper; +import com.beemdevelopment.aegis.helpers.FabScrollHelper; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.icons.IconType; +import com.beemdevelopment.aegis.importers.DatabaseImporter; +import com.beemdevelopment.aegis.importers.DatabaseImporterEntryException; +import com.beemdevelopment.aegis.importers.DatabaseImporterException; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.models.ImportEntry; +import com.beemdevelopment.aegis.ui.tasks.IconOptimizationTask; +import com.beemdevelopment.aegis.ui.tasks.RootShellTask; +import com.beemdevelopment.aegis.ui.views.ImportEntriesAdapter; +import com.beemdevelopment.aegis.util.UUIDMap; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public class ImportEntriesActivity extends AegisActivity { + private View _view; + private Menu _menu; + private RecyclerView _entriesView; + private ImportEntriesAdapter _adapter; + private FabScrollHelper _fabScrollHelper; + + private UUIDMap _importedGroups; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_import_entries); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + + _view = findViewById(R.id.importEntriesRootView); + + ActionBar bar = getSupportActionBar(); + bar.setHomeAsUpIndicator(R.drawable.ic_outline_close_24); + bar.setDisplayHomeAsUpEnabled(true); + + _adapter = new ImportEntriesAdapter(); + _entriesView = findViewById(R.id.list_entries); + _entriesView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + _fabScrollHelper.onScroll(dx, dy); + } + }); + + LinearLayoutManager layoutManager = new LinearLayoutManager(this); + _entriesView.setLayoutManager(layoutManager); + _entriesView.setAdapter(_adapter); + _entriesView.setNestedScrollingEnabled(false); + + FloatingActionButton fab = findViewById(R.id.fab); + fab.setOnClickListener(v -> { + if (_vaultManager.getVault().getEntries().size() > 0 + && _menu.findItem(R.id.toggle_wipe_vault).isChecked()) { + showWipeEntriesDialog(); + } else { + saveAndFinish(false); + } + }); + _fabScrollHelper = new FabScrollHelper(fab); + + DatabaseImporter.Definition importerDef = (DatabaseImporter.Definition) getIntent().getSerializableExtra("importerDef"); + startImport(importerDef, (File) getIntent().getSerializableExtra("file")); + } + + private void startImport(DatabaseImporter.Definition importerDef, @Nullable File file) { + DatabaseImporter importer = DatabaseImporter.create(this, importerDef.getType()); + if (file == null) { + if (importer.isInstalledAppVersionSupported()) { + startImportApp(importer); + } else { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.warning) + .setMessage(getString(R.string.app_version_error, importerDef.getName())) + .setCancelable(false) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(R.string.yes, (dialog1, which) -> { + startImportApp(importer); + }) + .setNegativeButton(R.string.no, (dialog1, which) -> { + finish(); + }) + .create()); + } + } else { + startImportFile(importer, file); + } + } + + private void startImportFile(@NonNull DatabaseImporter importer, @NonNull File file) { + try (InputStream stream = new FileInputStream(file)) { + DatabaseImporter.State state = importer.read(stream); + processImporterState(state); + } catch (FileNotFoundException e) { + Toast.makeText(this, R.string.file_not_found, Toast.LENGTH_SHORT).show(); + } catch (DatabaseImporterException | IOException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.reading_file_error, e, (dialog, which) -> finish()); + } + } + + private void startImportApp(@NonNull DatabaseImporter importer) { + RootShellTask task = new RootShellTask(this, shell -> { + if (isFinishing()) { + return; + } + + if (shell == null || !shell.isRoot()) { + Toast.makeText(this, R.string.root_error, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + try { + DatabaseImporter.State state = importer.readFromApp(shell); + processImporterState(state); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + Toast.makeText(this, R.string.app_lookup_error, Toast.LENGTH_SHORT).show(); + finish(); + } catch (DatabaseImporterException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.reading_file_error, e, (dialog, which) -> finish()); + } finally { + try { + shell.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }); + task.execute(this); + } + + private void processImporterState(DatabaseImporter.State state) { + try { + if (state.isEncrypted()) { + state.decrypt(this, new DatabaseImporter.DecryptListener() { + @Override + public void onStateDecrypted(DatabaseImporter.State state) { + processDecryptedImporterState(state); + } + + @Override + public void onError(Exception e) { + e.printStackTrace(); + Dialogs.showErrorDialog(ImportEntriesActivity.this, R.string.decryption_error, e, (dialog, which) -> finish()); + } + + @Override + public void onCanceled() { + finish(); + } + }); + } else { + processDecryptedImporterState(state); + } + } catch (DatabaseImporterException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.parsing_file_error, e, (dialog, which) -> finish()); + } + } + + private void processDecryptedImporterState(DatabaseImporter.State state) { + DatabaseImporter.Result result; + try { + result = state.convert(); + } catch (DatabaseImporterException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.parsing_file_error, e, (dialog, which) -> finish()); + return; + } + + Map icons = result.getEntries().getValues().stream() + .filter(e -> e.getIcon() != null + && !e.getIcon().getType().equals(IconType.SVG) + && !BitmapHelper.isVaultEntryIconOptimized(e.getIcon())) + .collect(Collectors.toMap(VaultEntry::getUUID, VaultEntry::getIcon)); + if (!icons.isEmpty()) { + IconOptimizationTask task = new IconOptimizationTask(this, newIcons -> { + for (Map.Entry mapEntry : newIcons.entrySet()) { + VaultEntry entry = result.getEntries().getByUUID(mapEntry.getKey()); + entry.setIcon(mapEntry.getValue()); + } + + processImporterResult(result); + }); + task.execute(getLifecycle(), icons); + } else { + processImporterResult(result); + } + } + + private void processImporterResult(DatabaseImporter.Result result) { + List importEntries = new ArrayList<>(); + for (VaultEntry entry : result.getEntries().getValues()) { + ImportEntry importEntry = new ImportEntry(entry); + _adapter.addEntry(importEntry); + importEntries.add(importEntry); + } + + _importedGroups = result.getGroups(); + + List errors = result.getErrors(); + if (errors.size() > 0) { + String message = getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size()); + Dialogs.showMultiExceptionDialog(this, R.string.import_error_title, message, errors, null); + } + + findDuplicates(importEntries); + } + + private void showWipeEntriesDialog() { + Dialogs.showCheckboxDialog(this, R.string.dialog_wipe_entries_title, + R.string.dialog_wipe_entries_message, + R.string.dialog_wipe_entries_checkbox, + this::saveAndFinish + ); + } + + private void saveAndFinish(boolean wipeEntries) { + VaultRepository vault = _vaultManager.getVault(); + if (wipeEntries) { + vault.wipeContents(); + } + + // Given the list of selected entries, collect the UUID's of all groups + // that we're actually going to import + List selectedEntries = _adapter.getCheckedEntries(); + List selectedGroupUuids = new ArrayList<>(); + for (ImportEntry entry : selectedEntries) { + selectedGroupUuids.addAll(entry.getEntry().getGroups()); + } + + // Add all of the new groups to the vault. If a group with the same name already + // exists in the vault, rewrite all entries in that group to reference the existing group. + for (VaultGroup importedGroup : _importedGroups) { + if (!selectedGroupUuids.contains(importedGroup.getUUID())) { + continue; + } + + VaultGroup existingGroup = vault.findGroupByUUID(importedGroup.getUUID()); + if (existingGroup != null) { + continue; + } + + existingGroup = vault.findGroupByName(importedGroup.getName()); + if (existingGroup == null) { + vault.addGroup(importedGroup); + } else { + for (ImportEntry entry : selectedEntries) { + Set entryGroups = entry.getEntry().getGroups(); + if (entryGroups.contains(importedGroup.getUUID())) { + entryGroups.remove(importedGroup.getUUID()); + entryGroups.add(existingGroup.getUUID()); + } + } + } + } + + for (ImportEntry selectedEntry : selectedEntries) { + VaultEntry entry = selectedEntry.getEntry(); + + // temporary: randomize the UUID of duplicate entries and add them anyway + if (vault.isEntryDuplicate(entry)) { + entry.resetUUID(); + } + + vault.addEntry(entry); + } + + if (saveAndBackupVault()) { + String toastMessage = getResources().getQuantityString(R.plurals.imported_entries_count, selectedEntries.size(), selectedEntries.size()); + Toast.makeText(this, toastMessage, Toast.LENGTH_SHORT).show(); + + + setResult(RESULT_OK, null); + + if (_iconPackManager.hasIconPack()) { + ArrayList assignIconEntriesIds = new ArrayList<>(); + Intent assignIconIntent = new Intent(getBaseContext(), AssignIconsActivity.class); + for (ImportEntry entry : selectedEntries) { + assignIconEntriesIds.add(entry.getEntry().getUUID()); + } + + assignIconIntent.putExtra("entries", assignIconEntriesIds); + + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this) + .setTitle(R.string.import_assign_icons_dialog_title) + .setMessage(R.string.import_assign_icons_dialog_text) + .setPositiveButton(android.R.string.yes, (dialog, which) -> { + startActivity(assignIconIntent); + finish(); + }) + .setNegativeButton(android.R.string.no, ((dialogInterface, i) -> finish())) + .create()); + } else { + finish(); + } + } + } + + private void findDuplicates(List importEntries) { + List duplicateEntries = new ArrayList<>(); + for (ImportEntry importEntry: importEntries) { + boolean exists = _vaultManager.getVault().getEntries().stream().anyMatch(item -> + item.getIssuer().equals(importEntry.getEntry().getIssuer()) && + Arrays.equals(item.getInfo().getSecret(), importEntry.getEntry().getInfo().getSecret())); + + if (exists) { + duplicateEntries.add(importEntry.getEntry().getUUID()); + } + } + + if (duplicateEntries.size() == 0) { + return; + } + + _adapter.setCheckboxStates(duplicateEntries, false); + Snackbar snackbar = Snackbar.make(_view, getResources().getQuantityString(R.plurals.import_duplicate_toast, duplicateEntries.size(), duplicateEntries.size()), Snackbar.LENGTH_INDEFINITE); + snackbar.addCallback(new Snackbar.Callback() { + @Override + public void onShown(Snackbar sb) { + int snackbarHeight = sb.getView().getHeight(); + + _entriesView.setPadding( + _entriesView.getPaddingLeft(), + _entriesView.getPaddingTop(), + _entriesView.getPaddingRight(), + _entriesView.getPaddingBottom() + snackbarHeight * 2 + ); + } + + @Override + public void onDismissed(Snackbar sb, int event) { + int snackbarHeight = sb.getView().getHeight(); + + _entriesView.setPadding( + _entriesView.getPaddingLeft(), + _entriesView.getPaddingTop(), + _entriesView.getPaddingRight(), + _entriesView.getPaddingBottom() - snackbarHeight * 2 + ); + } + }); + snackbar.setAction(R.string.undo, new View.OnClickListener() { + @Override + public void onClick(View v) { + _adapter.setCheckboxStates(duplicateEntries, true); + } + }); + snackbar.show(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + _menu = menu; + getMenuInflater().inflate(R.menu.menu_import_entries, _menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == android.R.id.home) { + finish(); + } else if (itemId == R.id.toggle_checkboxes) { + _adapter.toggleCheckboxes(); + } else if (itemId == R.id.toggle_wipe_vault) { + item.setChecked(!item.isChecked()); + } else { + return super.onOptionsItemSelected(item); + } + + return true; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java new file mode 100644 index 0000000..e7aecc1 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java @@ -0,0 +1,131 @@ +package com.beemdevelopment.aegis.ui; + +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_BIOMETRIC; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_INVALID; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_NONE; +import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_PASS; + +import android.os.Bundle; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.intro.IntroBaseActivity; +import com.beemdevelopment.aegis.ui.intro.SlideFragment; +import com.beemdevelopment.aegis.ui.slides.DoneSlide; +import com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide; +import com.beemdevelopment.aegis.ui.slides.SecuritySetupSlide; +import com.beemdevelopment.aegis.ui.slides.WelcomeSlide; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultFileCredentials; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.beemdevelopment.aegis.vault.slots.BiometricSlot; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; + +public class IntroActivity extends IntroBaseActivity { + // Permission request codes + private static final int CODE_PERM_NOTIFICATIONS = 0; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + addSlide(WelcomeSlide.class); + addSlide(SecurityPickerSlide.class); + addSlide(SecuritySetupSlide.class); + addSlide(DoneSlide.class); + } + + @Override + protected boolean onBeforeSlideChanged(Class oldSlide, @NonNull Class newSlide) { + // hide the keyboard before every slide change + InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(findViewById(android.R.id.content).getWindowToken(), 0); + + if (oldSlide == SecurityPickerSlide.class + && newSlide == SecuritySetupSlide.class + && getState().getInt("cryptType", CRYPT_TYPE_INVALID) == CRYPT_TYPE_NONE) { + skipToSlide(DoneSlide.class); + return true; + } + + if (oldSlide == WelcomeSlide.class + && newSlide == SecurityPickerSlide.class + && getState().getBoolean("imported")) { + skipToSlide(DoneSlide.class); + return true; + } + + // on the welcome page, we don't want the keyboard to push any views up + getWindow().setSoftInputMode(newSlide == WelcomeSlide.class + ? WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + : WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + + return false; + } + + @Override + protected void onAfterSlideChanged(@Nullable Class oldSlide, @NonNull Class newSlide) { + // If the user has enabled encryption, we need to request permission to show notifications + // in order to be able to show the "Vault unlocked" notification. + // + // NOTE: Disabled for now. See issue: #1047 + /*if (newSlide == DoneSlide.class && getState().getSerializable("creds") != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + PermissionHelper.request(this, CODE_PERM_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS); + } + }*/ + } + + @Override + protected void onDonePressed() { + Bundle state = getState(); + + VaultFileCredentials creds = (VaultFileCredentials) state.getSerializable("creds"); + if (!state.getBoolean("imported")) { + int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID); + if (cryptType == CRYPT_TYPE_INVALID + || (cryptType == CRYPT_TYPE_NONE && creds != null) + || (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class))) + || (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) { + throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds)); + } + + try { + _vaultManager.initNew(creds); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_init_error, e); + return; + } + } else { + VaultFile vaultFile; + try { + vaultFile = VaultRepository.readVaultFile(this); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e); + return; + } + + try { + _vaultManager.loadFrom(vaultFile, creds); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e); + return; + } + } + + // skip the intro from now on + _prefs.setIntroDone(true); + + setResult(RESULT_OK); + finish(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java new file mode 100644 index 0000000..a06ca77 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java @@ -0,0 +1,40 @@ +package com.beemdevelopment.aegis.ui; + +import android.os.Bundle; + +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ThemeMap; +import com.beemdevelopment.aegis.helpers.ThemeHelper; +import com.mikepenz.aboutlibraries.LibsBuilder; +import com.mikepenz.aboutlibraries.ui.LibsActivity; + +import org.jetbrains.annotations.Nullable; + +import dagger.hilt.InstallIn; +import dagger.hilt.android.EarlyEntryPoint; +import dagger.hilt.android.EarlyEntryPoints; +import dagger.hilt.components.SingletonComponent; + +public class LicensesActivity extends LibsActivity { + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + LibsBuilder builder = new LibsBuilder() + .withSearchEnabled(true) + .withAboutMinimalDesign(true) + .withActivityTitle(getString(R.string.title_activity_licenses)); + setIntent(builder.intent(this)); + + Preferences _prefs = EarlyEntryPoints.get(getApplicationContext(), PrefEntryPoint.class).getPreferences(); + ThemeHelper themeHelper = new ThemeHelper(this, _prefs); + themeHelper.setTheme(ThemeMap.DEFAULT); + + super.onCreate(savedInstanceState); + } + + @EarlyEntryPoint + @InstallIn(SingletonComponent.class) + public interface PrefEntryPoint { + Preferences getPreferences(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java new file mode 100644 index 0000000..592d15d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -0,0 +1,1487 @@ +package com.beemdevelopment.aegis.ui; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.PersistableBundle; +import android.provider.Settings; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.StyleSpan; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.SearchView; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.beemdevelopment.aegis.GroupPlaceholderType; +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.SortCategory; +import com.beemdevelopment.aegis.helpers.BitmapHelper; +import com.beemdevelopment.aegis.helpers.DropdownHelper; +import com.beemdevelopment.aegis.helpers.FabScrollHelper; +import com.beemdevelopment.aegis.helpers.PermissionHelper; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.icons.IconType; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.OtpInfoException; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.fragments.preferences.BackupsPreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment; +import com.beemdevelopment.aegis.ui.models.ErrorCardInfo; +import com.beemdevelopment.aegis.ui.models.VaultGroupModel; +import com.beemdevelopment.aegis.ui.tasks.IconOptimizationTask; +import com.beemdevelopment.aegis.ui.tasks.QrDecodeTask; +import com.beemdevelopment.aegis.ui.views.EntryListView; +import com.beemdevelopment.aegis.util.TimeUtils; +import com.beemdevelopment.aegis.util.UUIDMap; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; +import com.beemdevelopment.aegis.vault.VaultFile; +import com.beemdevelopment.aegis.vault.VaultGroup; +import com.beemdevelopment.aegis.vault.VaultRepository; +import com.beemdevelopment.aegis.vault.VaultRepositoryException; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; +import com.google.common.base.Strings; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +public class MainActivity extends AegisActivity implements EntryListView.Listener { + // Permission request codes + private static final int CODE_PERM_CAMERA = 0; + + private boolean _loaded; + private boolean _isRecreated; + private boolean _isDPadPressed; + private boolean _isDoingIntro; + private boolean _isAuthenticating; + + private String _submittedSearchQuery; + private String _pendingSearchQuery; + + private List _selectedEntries; + + private Menu _menu; + private SearchView _searchView; + private EntryListView _entryListView; + + private Collection _groups; + private ChipGroup _groupChip; + private Set _groupFilter; + private Set _prefGroupFilter; + + private FabScrollHelper _fabScrollHelper; + + private ActionMode _actionMode; + private ActionMode.Callback _actionModeCallbacks = new ActionModeCallbacks(); + + private LockBackPressHandler _lockBackPressHandler; + private SearchViewBackPressHandler _searchViewBackPressHandler; + private ActionModeBackPressHandler _actionModeBackPressHandler; + + private final ActivityResultLauncher authResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + _isAuthenticating = false; + if (activityResult.getResultCode() == RESULT_OK) { + onDecryptResult(); + } + }); + + private final ActivityResultLauncher introResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + _isDoingIntro = false; + if (activityResult.getResultCode() == RESULT_OK) { + onIntroResult(); + } + }); + + private final ActivityResultLauncher scanResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onScanResult(activityResult.getData()); + }); + + private final ActivityResultLauncher assignIconsResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onAssignIconsResult(); + }); + + private final ActivityResultLauncher preferenceResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> onPreferencesResult()); + + private final ActivityResultLauncher editEntryResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onEditEntryResult(); + }); + + private final ActivityResultLauncher addEntryResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { + return; + } + onAddEntryResult(activityResult.getData()); + }); + + private final ActivityResultLauncher codeScanResultLauncher = + registerForActivityResult(new StartActivityForResult(), activityResult -> { + if (activityResult.getResultCode() == RESULT_OK && activityResult.getData() != null) { + onScanImageResult(activityResult.getData()); + } + }); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + _loaded = false; + _isDPadPressed = false; + _isDoingIntro = false; + _isAuthenticating = false; + if (savedInstanceState != null) { + _isRecreated = true; + _pendingSearchQuery = savedInstanceState.getString("pendingSearchQuery"); + _submittedSearchQuery = savedInstanceState.getString("submittedSearchQuery"); + _isDoingIntro = savedInstanceState.getBoolean("isDoingIntro"); + _isAuthenticating = savedInstanceState.getBoolean("isAuthenticating"); + } + + _lockBackPressHandler = new LockBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _lockBackPressHandler); + _searchViewBackPressHandler = new SearchViewBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _searchViewBackPressHandler); + _actionModeBackPressHandler = new ActionModeBackPressHandler(); + getOnBackPressedDispatcher().addCallback(this, _actionModeBackPressHandler); + + _entryListView = (EntryListView) getSupportFragmentManager().findFragmentById(R.id.key_profiles); + _entryListView.setListener(this); + _entryListView.setCodeGroupSize(_prefs.getCodeGroupSize()); + _entryListView.setAccountNamePosition(_prefs.getAccountNamePosition()); + _entryListView.setShowIcon(_prefs.isIconVisible()); + _entryListView.setShowExpirationState(_prefs.getShowExpirationState()); + _entryListView.setShowNextCode(_prefs.getShowNextCode()); + _entryListView.setOnlyShowNecessaryAccountNames(_prefs.onlyShowNecessaryAccountNames()); + _entryListView.setHighlightEntry(_prefs.isEntryHighlightEnabled()); + _entryListView.setPauseFocused(_prefs.isPauseFocusedEnabled()); + _entryListView.setTapToReveal(_prefs.isTapToRevealEnabled()); + _entryListView.setTapToRevealTime(_prefs.getTapToRevealTime()); + _entryListView.setViewMode(_prefs.getCurrentViewMode()); + _entryListView.setSortCategory(_prefs.getCurrentSortCategory(), false); + _entryListView.setCopyBehavior(_prefs.getCopyBehavior()); + _entryListView.setSearchBehaviorMask(_prefs.getSearchBehaviorMask()); + _prefGroupFilter = _prefs.getGroupFilter(); + + FloatingActionButton fab = findViewById(R.id.fab); + fab.setOnClickListener(v -> { + View view = getLayoutInflater().inflate(R.layout.dialog_add_entry, null); + BottomSheetDialog dialog = new BottomSheetDialog(this); + dialog.setContentView(view); + + view.findViewById(R.id.fab_enter).setOnClickListener(v1 -> { + dialog.dismiss(); + startEditEntryActivityForManual(); + }); + view.findViewById(R.id.fab_scan_image).setOnClickListener(v2 -> { + dialog.dismiss(); + startScanImageActivity(); + }); + view.findViewById(R.id.fab_scan).setOnClickListener(v3 -> { + dialog.dismiss(); + startScanActivity(); + }); + + Dialogs.showSecureDialog(dialog); + }); + + _groupChip = findViewById(R.id.groupChipGroup); + _fabScrollHelper = new FabScrollHelper(fab); + _selectedEntries = new ArrayList<>(); + } + + public void setGroups(Collection groups) { + _groups = groups; + _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); + + if (_prefGroupFilter != null) { + Set groupFilter = cleanGroupFilter(_prefGroupFilter); + _prefGroupFilter = null; + if (!groupFilter.isEmpty()) { + _groupFilter = groupFilter; + _entryListView.setGroupFilter(groupFilter); + } + } else if (_groupFilter != null) { + Set groupFilter = cleanGroupFilter(_groupFilter); + if (!_groupFilter.equals(groupFilter)) { + _groupFilter = groupFilter; + _entryListView.setGroupFilter(groupFilter); + } + } + + _entryListView.setGroups(groups); + initializeGroups(); + } + + private void initializeGroups() { + _groupChip.removeAllViews(); + _groupChip.setSingleSelection(!_prefs.isGroupMultiselectEnabled()); + + for (VaultGroup group : _groups) { + addChipTo(_groupChip, new VaultGroupModel(group)); + } + + GroupPlaceholderType placeholderType = GroupPlaceholderType.NO_GROUP; + addChipTo(_groupChip, new VaultGroupModel(this, placeholderType)); + addSaveChip(_groupChip); + } + + private Set cleanGroupFilter(Set groupFilter) { + Set groupUuids = _groups.stream().map(UUIDMap.Value::getUUID).collect(Collectors.toSet()); + + return groupFilter.stream() + .filter(g -> g == null || groupUuids.contains(g)) + .collect(Collectors.toSet()); + } + + private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) { + Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false); + chip.setText(group.getName()); + chip.setCheckable(true); + chip.setCheckedIconVisible(false); + chip.setChecked(_groupFilter != null && _groupFilter.contains(group.getUUID())); + + if (group.isPlaceholder()) { + GroupPlaceholderType groupPlaceholderType = group.getPlaceholderType(); + chip.setTag(groupPlaceholderType); + + if (groupPlaceholderType == GroupPlaceholderType.ALL) { + chip.setChecked(_groupFilter == null); + } else if (groupPlaceholderType == GroupPlaceholderType.NO_GROUP) { + chip.setChecked(_groupFilter != null && _groupFilter.contains(null)); + } + } else { + chip.setTag(group); + } + + chip.setOnCheckedChangeListener((group1, isChecked) -> { + if (_actionMode != null) { + _actionMode.finish(); + } + + setSaveChipVisibility(true); + + // Reset group filter if last checked group gets unchecked + if (!isChecked && _groupFilter.size() == 1) { + Set groupFilter = new HashSet<>(); + + chipGroup.clearCheck(); + _groupFilter = groupFilter; + _entryListView.setGroupFilter(groupFilter); + return; + } + + _groupFilter = getGroupFilter(chipGroup); + _entryListView.setGroupFilter(_groupFilter); + }); + + chipGroup.addView(chip); + } + + private void addSaveChip(ChipGroup chipGroup) { + Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false); + + chip.setText(getString(R.string.save)); + chip.setVisibility(View.GONE); + chip.setChipStrokeWidth(0); + chip.setCheckable(false); + chip.setChipBackgroundColorResource(android.R.color.transparent); + chip.setTextColor(MaterialColors.getColor(chip.getRootView(), com.google.android.material.R.attr.colorSecondary)); + chip.setClickable(true); + chip.setCheckedIconVisible(false); + chip.setOnClickListener(v -> { + onSaveGroupFilter(_groupFilter); + setSaveChipVisibility(false); + }); + + chipGroup.addView(chip); + } + + private void setSaveChipVisibility(boolean visible) { + Chip saveChip = (Chip) _groupChip.getChildAt(_groupChip.getChildCount() - 1); + saveChip.setChecked(false); + saveChip.setVisibility(visible ? View.VISIBLE : View.GONE); + } + + private static Set getGroupFilter(ChipGroup chipGroup) { + return chipGroup.getCheckedChipIds().stream() + .filter(Objects::nonNull) + .map(i -> { + Chip chip = chipGroup.findViewById(i); + + if (chip.getTag() instanceof VaultGroupModel) { + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getUUID(); + } + + return null; + }) + .collect(Collectors.toSet()); + } + + @Override + protected void onDestroy() { + _entryListView.setListener(null); + super.onDestroy(); + } + + @Override + protected void onPause() { + Map usageMap = _entryListView.getUsageCounts(); + if (usageMap != null) { + _prefs.setUsageCount(usageMap); + } + + Map lastUsedMap = _entryListView.getLastUsedTimestamps(); + if (lastUsedMap != null) { + _prefs.setLastUsedTimestamps(lastUsedMap); + } + + super.onPause(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle instance) { + super.onSaveInstanceState(instance); + instance.putString("pendingSearchQuery", _pendingSearchQuery); + instance.putString("submittedSearchQuery", _submittedSearchQuery); + instance.putBoolean("isDoingIntro", _isDoingIntro); + instance.putBoolean("isAuthenticating", _isAuthenticating); + + if (_groupFilter != null) { + instance.putSerializable("prefGroupFilter", new HashSet<>(_groupFilter)); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (!PermissionHelper.checkResults(grantResults)) { + Toast.makeText(this, getString(R.string.permission_denied), Toast.LENGTH_SHORT).show(); + return; + } + + if (requestCode == CODE_PERM_CAMERA) { + startScanActivity(); + } + + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + _isDPadPressed = isDPadKey(keyCode); + return super.onKeyDown(keyCode, event); + } + + private static boolean isDPadKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_LEFT; + } + + @Override + public void onEntryListTouch() { + _isDPadPressed = false; + + if (_searchView != null && !_searchView.isIconified()) { + if (ViewCompat.getRootWindowInsets(findViewById(android.R.id.content).getRootView()).isVisible(WindowInsetsCompat.Type.ime())) { + InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null && getCurrentFocus() != null) { + inputMethodManager.hideSoftInputFromWindow(getCurrentFocus().getWindowToken(), 0); + } + } + } + } + + private void onPreferencesResult() { + // refresh the entire entry list if needed + if (_loaded) { + recreate(); + } + } + + private void startEditEntryActivityForNew(VaultEntry entry) { + Intent intent = new Intent(this, EditEntryActivity.class); + intent.putExtra("newEntry", entry); + intent.putExtra("isManual", false); + addEntryResultLauncher.launch(intent); + } + + private void startEditEntryActivityForManual() { + Intent intent = new Intent(this, EditEntryActivity.class); + intent.putExtra("newEntry", VaultEntry.getDefault()); + intent.putExtra("isManual", true); + addEntryResultLauncher.launch(intent); + } + + private void startEditEntryActivity(VaultEntry entry) { + Intent intent = new Intent(this, EditEntryActivity.class); + intent.putExtra("entryUUID", entry.getUUID()); + editEntryResultLauncher.launch(intent); + } + + private void startAssignIconsActivity(List entries) { + ArrayList assignIconEntriesIds = new ArrayList<>(); + Intent assignIconIntent = new Intent(getBaseContext(), AssignIconsActivity.class); + for (VaultEntry entry : entries) { + assignIconEntriesIds.add(entry.getUUID()); + } + + assignIconIntent.putExtra("entries", assignIconEntriesIds); + assignIconsResultLauncher.launch(assignIconIntent); + } + + private void startAssignGroupsDialog() { + View view = LayoutInflater.from(this).inflate(R.layout.dialog_select_group, null); + TextInputLayout groupSelectionLayout = view.findViewById(R.id.group_selection_layout); + AutoCompleteTextView groupsSelection = view.findViewById(R.id.group_selection_dropdown); + TextInputLayout newGroupLayout = view.findViewById(R.id.text_group_name_layout); + TextInputEditText newGroupText = view.findViewById(R.id.text_group_name); + + Collection groups = _vaultManager.getVault().getUsedGroups(); + List groupModels = new ArrayList<>(); + groupModels.add(new VaultGroupModel(this, GroupPlaceholderType.NEW_GROUP)); + groupModels.addAll(groups.stream().map(VaultGroupModel::new).collect(Collectors.toList())); + DropdownHelper.fillDropdown(this, groupsSelection, groupModels); + + AtomicReference groupModelRef = new AtomicReference<>(); + groupsSelection.setOnItemClickListener((parent, view1, position, id) -> { + VaultGroupModel groupModel = (VaultGroupModel) parent.getItemAtPosition(position); + groupModelRef.set(groupModel); + + if (groupModel.isPlaceholder()) { + newGroupLayout.setVisibility(View.VISIBLE); + newGroupText.requestFocus(); + } else { + newGroupLayout.setVisibility(View.GONE); + } + + groupSelectionLayout.setError(null); + }); + + AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.assign_groups) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + dialog.setOnShowListener(d -> { + Button btnPos = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + btnPos.setOnClickListener(v -> { + VaultGroupModel groupModel = groupModelRef.get(); + if (groupModel == null) { + groupSelectionLayout.setError(getString(R.string.error_required_field)); + return; + } + + if (groupModel.isPlaceholder()) { + String newGroupName = newGroupText.getText().toString().trim(); + if (newGroupName.isEmpty()) { + newGroupLayout.setError(getString(R.string.error_required_field)); + return; + } + + VaultGroup group = new VaultGroup(newGroupName); + _vaultManager.getVault().addGroup(group); + groupModel = new VaultGroupModel(group); + } + + for (VaultEntry selectedEntry : _selectedEntries) { + selectedEntry.addGroup(groupModel.getUUID()); + } + + dialog.dismiss(); + saveAndBackupVault(); + _actionMode.finish(); + setGroups(_vaultManager.getVault().getUsedGroups()); + }); + }); + + Dialogs.showSecureDialog(dialog); + } + + private void startIntroActivity() { + if (!_isDoingIntro) { + Intent intro = new Intent(this, IntroActivity.class); + introResultLauncher.launch(intro); + _isDoingIntro = true; + } + } + + private void onScanResult(Intent data) { + List entries = (ArrayList) data.getSerializableExtra("entries"); + if (entries != null) { + importScannedEntries(entries); + } + } + + private void onAddEntryResult(Intent data) { + if (_loaded) { + UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); + VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + _entryListView.onEntryAdded(entry); + } + } + + private void onEditEntryResult() { + if (_loaded) { + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + } + } + + private void onAssignIconsResult() { + if (_loaded) { + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + } + } + + private void onScanImageResult(Intent intent) { + if (intent.getData() != null) { + startDecodeQrCodeImages(Collections.singletonList(intent.getData())); + return; + } + + if (intent.getClipData() != null) { + ClipData data = intent.getClipData(); + + List uris = new ArrayList<>(); + for (int i = 0; i < data.getItemCount(); i++) { + ClipData.Item item = data.getItemAt(i); + if (item.getUri() != null) { + uris.add(item.getUri()); + } + } + + if (uris.size() > 0) { + startDecodeQrCodeImages(uris); + } + } + } + + private static CharSequence buildImportError(String fileName, Throwable e) { + SpannableStringBuilder builder = new SpannableStringBuilder(String.format("%s:\n%s", fileName, e)); + builder.setSpan(new StyleSpan(Typeface.BOLD), 0, fileName.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return builder; + } + + private void startDecodeQrCodeImages(List uris) { + QrDecodeTask task = new QrDecodeTask(this, (results) -> { + List errors = new ArrayList<>(); + List entries = new ArrayList<>(); + List googleAuthExports = new ArrayList<>(); + + for (QrDecodeTask.Result res : results) { + if (res.getException() != null) { + errors.add(buildImportError(res.getFileName(), res.getException())); + continue; + } + + try { + Uri scanned = Uri.parse(res.getResult().getText()); + if (Objects.equals(scanned.getScheme(), GoogleAuthInfo.SCHEME_EXPORT)) { + GoogleAuthInfo.Export export = GoogleAuthInfo.parseExportUri(scanned); + for (GoogleAuthInfo info: export.getEntries()) { + VaultEntry entry = new VaultEntry(info); + entries.add(entry); + } + googleAuthExports.add(export); + } else { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(res.getResult().getText()); + VaultEntry entry = new VaultEntry(info); + entries.add(entry); + } + } catch (GoogleAuthInfoException e) { + errors.add(buildImportError(res.getFileName(), e)); + } + } + + final DialogInterface.OnClickListener dialogDismissHandler = (dialog, which) -> importScannedEntries(entries); + if (!googleAuthExports.isEmpty()) { + boolean isSingleBatch = GoogleAuthInfo.Export.isSingleBatch(googleAuthExports); + if (!isSingleBatch && errors.size() > 0) { + errors.add(getString(R.string.unrelated_google_auth_batches_error)); + Dialogs.showMultiErrorDialog(this, R.string.import_error_title, getString(R.string.no_tokens_can_be_imported), errors, null); + return; + } else if (!isSingleBatch) { + Dialogs.showErrorDialog(this, R.string.import_google_auth_failure, getString(R.string.unrelated_google_auth_batches_error)); + return; + } else { + List missingIndices = GoogleAuthInfo.Export.getMissingIndices(googleAuthExports); + if (missingIndices.size() != 0) { + Dialogs.showPartialGoogleAuthImportWarningDialog(this, missingIndices, entries.size(), errors, dialogDismissHandler); + return; + } + } + } + + if ((errors.size() > 0 && results.size() > 1) || errors.size() > 1) { + Dialogs.showMultiErrorDialog(this, R.string.import_error_title, getString(R.string.unable_to_read_qrcode_files, uris.size() - errors.size(), uris.size()), errors, dialogDismissHandler); + } else if (errors.size() > 0) { + Dialogs.showErrorDialog(this, getString(R.string.unable_to_read_qrcode_file, results.get(0).getFileName()), errors.get(0), dialogDismissHandler); + } else { + importScannedEntries(entries); + } + }); + task.execute(getLifecycle(), uris); + } + + private void importScannedEntries(List entries) { + if (entries.size() == 1) { + startEditEntryActivityForNew(entries.get(0)); + } else if (entries.size() > 1) { + for (VaultEntry entry: entries) { + _vaultManager.getVault().addEntry(entry); + } + + if (saveAndBackupVault()) { + Toast.makeText(this, getResources().getQuantityString(R.plurals.added_new_entries, entries.size(), entries.size()), Toast.LENGTH_LONG).show(); + } + + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + } + } + + private void updateSortCategoryMenu() { + if (_menu != null) { + SortCategory category = _prefs.getCurrentSortCategory(); + _menu.findItem(category.getMenuItem()).setChecked(true); + } + } + + private void onIntroResult() { + loadEntries(); + } + + private void checkTimeSyncSetting() { + boolean autoTime = Settings.Global.getInt(getContentResolver(), Settings.Global.AUTO_TIME, 1) == 1; + if (!autoTime && _prefs.isTimeSyncWarningEnabled()) { + Dialogs.showTimeSyncWarningDialog(this, (dialog, which) -> { + Intent intent = new Intent(Settings.ACTION_DATE_SETTINGS); + startActivity(intent); + }); + } + } + + private void checkIconOptimization() { + if (!_vaultManager.getVault().areIconsOptimized()) { + Map oldIcons = _vaultManager.getVault().getEntries().stream() + .filter(e -> e.getIcon() != null + && !e.getIcon().getType().equals(IconType.SVG) + && !BitmapHelper.isVaultEntryIconOptimized(e.getIcon())) + .collect(Collectors.toMap(VaultEntry::getUUID, VaultEntry::getIcon)); + + if (!oldIcons.isEmpty()) { + IconOptimizationTask task = new IconOptimizationTask(this, this::onIconsOptimized); + task.execute(getLifecycle(), oldIcons); + } else { + onIconsOptimized(Collections.emptyMap()); + } + } + } + + private void onIconsOptimized(Map newIcons) { + for (Map.Entry mapEntry : newIcons.entrySet()) { + VaultEntry entry = _vaultManager.getVault().getEntryByUUID(mapEntry.getKey()); + entry.setIcon(mapEntry.getValue()); + } + + _vaultManager.getVault().setIconsOptimized(true); + saveAndBackupVault(); + + if (!newIcons.isEmpty()) { + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + } + } + + private void onDecryptResult() { + _auditLogRepository.addVaultUnlockedEvent(); + + loadEntries(); + } + + private void startScanActivity() { + if (!PermissionHelper.request(this, CODE_PERM_CAMERA, Manifest.permission.CAMERA)) { + return; + } + + Intent scannerActivity = new Intent(getApplicationContext(), ScannerActivity.class); + scanResultLauncher.launch(scannerActivity); + } + + private void startScanImageActivity() { + Intent galleryIntent = new Intent(Intent.ACTION_PICK); + galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + galleryIntent.setDataAndType(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); + + Intent fileIntent = new Intent(Intent.ACTION_GET_CONTENT); + fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + fileIntent.setType("image/*"); + + Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.select_picture)); + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Intent[] { fileIntent }); + _vaultManager.fireIntentLauncher(this, chooserIntent, codeScanResultLauncher); + } + + private void startPreferencesActivity() { + startPreferencesActivity(null, null); + } + + private void startPreferencesActivity(Class fragmentType, String preference) { + Intent intent = new Intent(this, PreferencesActivity.class); + intent.putExtra("fragment", fragmentType); + intent.putExtra("pref", preference); + preferenceResultLauncher.launch(intent); + } + + private void doShortcutActions() { + Intent intent = getIntent(); + String action = intent.getStringExtra("action"); + if (action == null || !_vaultManager.isVaultLoaded()) { + return; + } + + switch (action) { + case "scan": + startScanActivity(); + break; + } + + intent.removeExtra("action"); + } + + private void handleIncomingIntent() { + if (!_vaultManager.isVaultLoaded()) { + return; + } + + Intent intent = getIntent(); + if (intent.getAction() == null) { + return; + } + + Uri uri; + switch (intent.getAction()) { + case Intent.ACTION_VIEW: + uri = intent.getData(); + if (uri != null) { + intent.setData(null); + intent.setAction(null); + + GoogleAuthInfo info; + try { + info = GoogleAuthInfo.parseUri(uri); + } catch (GoogleAuthInfoException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.unable_to_process_deeplink, e); + break; + } + + VaultEntry entry = new VaultEntry(info); + startEditEntryActivityForNew(entry); + } + break; + case Intent.ACTION_SEND: + if (intent.hasExtra(Intent.EXTRA_STREAM)) { + uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_STREAM); + + if (uri != null) { + startDecodeQrCodeImages(Collections.singletonList(uri)); + } + } + if (intent.hasExtra(Intent.EXTRA_TEXT)) { + String stringExtra = intent.getStringExtra(Intent.EXTRA_TEXT); + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_TEXT); + + if (stringExtra != null) { + GoogleAuthInfo info; + try { + info = GoogleAuthInfo.parseUri(stringExtra); + } catch (GoogleAuthInfoException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_process_shared_text, e); + break; + } + + VaultEntry entry = new VaultEntry(info); + startEditEntryActivityForNew(entry); + } + } + break; + case Intent.ACTION_SEND_MULTIPLE: + if (intent.hasExtra(Intent.EXTRA_STREAM)) { + List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + intent.setAction(null); + intent.removeExtra(Intent.EXTRA_STREAM); + + if (uris != null) { + uris = uris.stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + startDecodeQrCodeImages(uris); + } + } + break; + } + } + + @Override + protected void onStop() { + super.onStop(); + + _entryListView.onRefreshStop(); + } + + @Override + protected void onStart() { + super.onStart(); + + if (_vaultManager.isVaultInitNeeded()) { + if (_prefs.isIntroDone()) { + Toast.makeText(this, getString(R.string.vault_not_found), Toast.LENGTH_SHORT).show(); + } + startIntroActivity(); + return; + } + + // If the vault is not loaded yet, try to load it now in case it's plain text + if (!_vaultManager.isVaultLoaded()) { + VaultFile vaultFile; + try { + vaultFile = VaultRepository.readVaultFile(this); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> { + finish(); + }); + return; + } + + if (!vaultFile.isEncrypted()) { + try { + _vaultManager.loadFrom(vaultFile); + } catch (VaultRepositoryException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(this, R.string.vault_load_error, e, (dialog, which) -> { + finish(); + }); + return; + } + } + } + + if (!_vaultManager.isVaultLoaded()) { + startAuthActivity(false); + } else if (_loaded) { + // update the list of groups in the entry list view so that the chip gets updated + setGroups(_vaultManager.getVault().getUsedGroups()); + + // update the usage counts in case they are edited outside of the EntryListView + _entryListView.setUsageCounts(_prefs.getUsageCounts()); + + _entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps()); + + // refresh all codes to prevent showing old ones + _entryListView.refresh(false); + + _entryListView.onRefreshStart(); + } else { + loadEntries(); + checkTimeSyncSetting(); + checkIconOptimization(); + + _entryListView.onRefreshStart(); + } + + _lockBackPressHandler.setEnabled( + _vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_BACK_BUTTON) + ); + + handleIncomingIntent(); + updateLockIcon(); + updateSortCategoryMenu(); + doShortcutActions(); + updateErrorCard(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + _menu = menu; + getMenuInflater().inflate(R.menu.menu_main, menu); + + updateLockIcon(); + updateSortCategoryMenu(); + + MenuItem searchViewMenuItem = menu.findItem(R.id.mi_search); + _searchView = (SearchView) searchViewMenuItem.getActionView(); + _searchView.setMaxWidth(Integer.MAX_VALUE); + _searchView.setOnQueryTextFocusChangeListener((v, hasFocus) -> { + boolean enabled = _submittedSearchQuery != null || hasFocus; + _searchViewBackPressHandler.setEnabled(enabled); + }); + _searchView.setOnCloseListener(() -> { + boolean enabled = _submittedSearchQuery != null; + _searchViewBackPressHandler.setEnabled(enabled); + _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); + return false; + }); + + _searchView.setQueryHint(getString(R.string.search)); + _searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String s) { + setTitle(getString(R.string.search)); + getSupportActionBar().setSubtitle(s); + _entryListView.setSearchFilter(s); + _pendingSearchQuery = null; + _submittedSearchQuery = s; + collapseSearchView(); + _searchViewBackPressHandler.setEnabled(true); + return false; + } + + @Override + public boolean onQueryTextChange(String s) { + if (_submittedSearchQuery == null) { + _entryListView.setSearchFilter(s); + } + + _pendingSearchQuery = Strings.isNullOrEmpty(s) && !_searchView.isIconified() ? null : s; + if (_pendingSearchQuery != null) { + _entryListView.setSearchFilter(_pendingSearchQuery); + } + + return false; + } + }); + _searchView.setOnSearchClickListener(v -> { + String query = _submittedSearchQuery != null ? _submittedSearchQuery : _pendingSearchQuery; + _groupChip.setVisibility(View.GONE); + _searchView.setQuery(query, false); + }); + + if (_pendingSearchQuery != null) { + _searchView.setIconified(false); + _searchView.setQuery(_pendingSearchQuery, false); + _searchViewBackPressHandler.setEnabled(true); + } else if (_submittedSearchQuery != null) { + setTitle(getString(R.string.search)); + getSupportActionBar().setSubtitle(_submittedSearchQuery); + _entryListView.setSearchFilter(_submittedSearchQuery); + _searchViewBackPressHandler.setEnabled(true); + } else if (_prefs.getFocusSearchEnabled() && !_isRecreated) { + _searchView.setIconified(false); + _searchView.setFocusable(true); + _searchView.requestFocus(); + _searchView.requestFocusFromTouch(); + } + + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int itemId = item.getItemId(); + if (itemId == R.id.action_settings) { + startPreferencesActivity(); + } else if (itemId == R.id.action_about) { + Intent intent = new Intent(this, AboutActivity.class); + startActivity(intent); + } else if (itemId == R.id.action_lock) { + _vaultManager.lock(true); + } else { + if (item.getGroupId() == R.id.action_sort_category) { + item.setChecked(true); + + SortCategory sortCategory; + int subItemId = item.getItemId(); + if (subItemId == R.id.menu_sort_alphabetically) { + sortCategory = SortCategory.ISSUER; + } else if (subItemId == R.id.menu_sort_alphabetically_reverse) { + sortCategory = SortCategory.ISSUER_REVERSED; + } else if (subItemId == R.id.menu_sort_alphabetically_name) { + sortCategory = SortCategory.ACCOUNT; + } else if (subItemId == R.id.menu_sort_alphabetically_name_reverse) { + sortCategory = SortCategory.ACCOUNT_REVERSED; + } else if (subItemId == R.id.menu_sort_usage_count) { + sortCategory = SortCategory.USAGE_COUNT; + } else if (subItemId == R.id.menu_sort_last_used) { + sortCategory = SortCategory.LAST_USED; + } else { + sortCategory = SortCategory.CUSTOM; + } + + _entryListView.setSortCategory(sortCategory, true); + _prefs.setCurrentSortCategory(sortCategory); + } + + return super.onOptionsItemSelected(item); + } + + return true; + } + + private void collapseSearchView() { + _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); + _searchView.setQuery(null, false); + _searchView.setIconified(true); + } + + private void loadEntries() { + if (!_loaded) { + setGroups(_vaultManager.getVault().getUsedGroups()); + _entryListView.setUsageCounts(_prefs.getUsageCounts()); + _entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps()); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + if (!_isRecreated) { + _entryListView.runEntriesAnimation(); + } + _loaded = true; + } + } + + private void startAuthActivity(boolean inhibitBioPrompt) { + if (!_isAuthenticating) { + Intent intent = new Intent(this, AuthActivity.class); + intent.putExtra("inhibitBioPrompt", inhibitBioPrompt); + authResultLauncher.launch(intent); + _isAuthenticating = true; + } + } + + private void updateLockIcon() { + // hide the lock icon if the vault is not unlocked + if (_menu != null && _vaultManager.isVaultLoaded()) { + MenuItem item = _menu.findItem(R.id.action_lock); + item.setVisible(_vaultManager.getVault().isEncryptionEnabled()); + } + } + + private void updateErrorCard() { + ErrorCardInfo info = null; + + Preferences.BackupResult backupRes = _prefs.getErroredBackupResult(); + if (backupRes != null) { + info = new ErrorCardInfo(getString(R.string.backup_error_bar_message), view -> { + Dialogs.showBackupErrorDialog(this, backupRes, (dialog, which) -> { + startPreferencesActivity(BackupsPreferencesFragment.class, "pref_backups"); + }); + }); + } else if (_prefs.isBackupsReminderNeeded() && _prefs.isBackupReminderEnabled()) { + String text; + Date date = _prefs.getLatestBackupOrExportTime(); + if (date != null) { + text = getString(R.string.backup_reminder_bar_message_with_latest, TimeUtils.getElapsedSince(this, date)); + } else { + text = getString(R.string.backup_reminder_bar_message); + } + info = new ErrorCardInfo(text, view -> { + Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Error) + .setTitle(R.string.backup_reminder_bar_dialog_title) + .setMessage(R.string.backup_reminder_bar_dialog_summary) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(R.string.backup_reminder_bar_dialog_accept, (dialog, whichButton) -> { + startPreferencesActivity(BackupsPreferencesFragment.class, "pref_backups"); + }) + .setNegativeButton(android.R.string.cancel, null) + .create()); + }); + } else if (_prefs.isPlaintextBackupWarningNeeded()) { + info = new ErrorCardInfo(getString(R.string.backup_plaintext_export_warning), view -> showPlaintextExportWarningOptions()); + } + + _entryListView.setErrorCardInfo(info); + } + + private void showPlaintextExportWarningOptions() { + View view = LayoutInflater.from(this).inflate(R.layout.dialog_plaintext_warning, null); + + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(R.string.backup_plaintext_export_warning) + .setView(view) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + CheckBox checkBox = view.findViewById(R.id.checkbox_plaintext_warning); + checkBox.setChecked(false); + + dialog.setOnShowListener(d -> { + Button btnPos = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + + btnPos.setOnClickListener(l -> { + dialog.dismiss(); + + _prefs.setIsPlaintextBackupWarningDisabled(checkBox.isChecked()); + _prefs.setIsPlaintextBackupWarningNeeded(false); + + updateErrorCard(); + }); + }); + + Dialogs.showSecureDialog(dialog); + } + + @Override + public void onRestoreInstanceState(@Nullable Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if (savedInstanceState == null) { + return; + } + + HashSet filter = (HashSet) savedInstanceState.getSerializable("prefGroupFilter"); + if (filter != null) { + _prefGroupFilter = filter; + } + } + + @Override + public void onEntryClick(VaultEntry entry) { + if (_actionMode != null) { + if (_selectedEntries.isEmpty()) { + _actionMode.finish(); + } else { + setFavoriteMenuItemVisiblity(); + setIsMultipleSelected(_selectedEntries.size() > 1); + } + } + } + + @Override + public void onSelect(VaultEntry entry) { + _selectedEntries.add(entry); + } + + @Override + public void onDeselect(VaultEntry entry) { + _selectedEntries.remove(entry); + } + + private void setIsMultipleSelected(boolean multipleSelected) { + _entryListView.setIsLongPressDragEnabled(!multipleSelected); + _actionMode.getMenu().findItem(R.id.action_edit).setVisible(!multipleSelected); + _actionMode.getMenu().findItem(R.id.action_copy).setVisible(!multipleSelected); + } + + private void setAssignIconsMenuItemVisibility() { + MenuItem assignIconsMenuItem = _actionMode.getMenu().findItem(R.id.action_assign_icons); + assignIconsMenuItem.setVisible(_iconPackManager.hasIconPack()); + } + + private void setFavoriteMenuItemVisiblity() { + MenuItem toggleFavoriteMenuItem = _actionMode.getMenu().findItem(R.id.action_toggle_favorite); + + if (_selectedEntries.size() == 1){ + if (_selectedEntries.get(0).isFavorite()) { + toggleFavoriteMenuItem.setIcon(R.drawable.ic_filled_star_24); + toggleFavoriteMenuItem.setTitle(R.string.unfavorite); + } else { + toggleFavoriteMenuItem.setIcon(R.drawable.ic_outline_star_24); + toggleFavoriteMenuItem.setTitle(R.string.favorite); + } + } else { + toggleFavoriteMenuItem.setIcon(R.drawable.ic_outline_star_24); + toggleFavoriteMenuItem.setTitle(String.format("%s / %s", getString(R.string.favorite), getString(R.string.unfavorite))); + } + } + + @Override + public void onLongEntryClick(VaultEntry entry) { + if (!_selectedEntries.isEmpty()) { + return; + } + + _selectedEntries.add(entry); + _entryListView.setActionModeState(true, entry); + startActionMode(); + } + + private void startActionMode() { + _actionMode = startSupportActionMode(_actionModeCallbacks); + _actionModeBackPressHandler.setEnabled(true); + setFavoriteMenuItemVisiblity(); + setAssignIconsMenuItemVisibility(); + } + + @Override + public void onEntryMove(VaultEntry entry1, VaultEntry entry2) { + _vaultManager.getVault().moveEntry(entry1, entry2); + } + + @Override + public void onEntryDrop(VaultEntry entry) { + saveVault(); + } + + @Override + public void onEntryChange(VaultEntry entry) { + saveAndBackupVault(); + } + + public void onEntryCopy(VaultEntry entry) { + copyEntryCode(entry); + } + + @Override + public void onScroll(int dx, int dy) { + if (!_isDPadPressed) { + _fabScrollHelper.onScroll(dx, dy); + } + } + + @Override + public void onListChange() { _fabScrollHelper.setVisible(true); } + + @Override + public void onSaveGroupFilter(Set groupFilter) { + if (_vaultManager.getVault().isGroupsMigrationFresh()) { + saveAndBackupVault(); + } + _prefs.setGroupFilter(groupFilter); + } + + @Override + public void onLocked(boolean userInitiated) { + if (_actionMode != null) { + _actionMode.finish(); + } + if (_searchView != null && !_searchView.isIconified()) { + collapseSearchView(); + } + + _entryListView.clearEntries(); + _loaded = false; + + if (userInitiated) { + startAuthActivity(true); + } else { + super.onLocked(false); + } + } + + @Override + protected boolean saveAndBackupVault() { + boolean res = super.saveAndBackupVault(); + updateErrorCard(); + return res; + } + + @SuppressLint("InlinedApi") + private void copyEntryCode(VaultEntry entry) { + String otp; + try { + otp = entry.getInfo().getOtp(); + } catch (OtpInfoException e) { + return; + } + + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("text/plain", otp); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PersistableBundle extras = new PersistableBundle(); + extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + clip.getDescription().setExtras(extras); + } + clipboard.setPrimaryClip(clip); + if (_prefs.isMinimizeOnCopyEnabled()) { + moveTaskToBack(true); + } + } + + private class SearchViewBackPressHandler extends OnBackPressedCallback { + public SearchViewBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + if (!_searchView.isIconified() || _submittedSearchQuery != null) { + _submittedSearchQuery = null; + _pendingSearchQuery = null; + _entryListView.setSearchFilter(null); + + collapseSearchView(); + setTitle(R.string.app_name); + getSupportActionBar().setSubtitle(null); + } + } + } + + private class LockBackPressHandler extends OnBackPressedCallback { + public LockBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + if (_vaultManager.isAutoLockEnabled(Preferences.AUTO_LOCK_ON_BACK_BUTTON)) { + _vaultManager.lock(false); + } + } + } + + private class ActionModeBackPressHandler extends OnBackPressedCallback { + public ActionModeBackPressHandler() { + super(false); + } + + @Override + public void handleOnBackPressed() { + if (_actionMode != null) { + _actionMode.finish(); + } + } + } + + private class ActionModeCallbacks implements ActionMode.Callback { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_action_mode, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (_selectedEntries.size() == 0) { + mode.finish(); + return true; + } + + int itemId = item.getItemId(); + if (itemId == R.id.action_copy) { + copyEntryCode(_selectedEntries.get(0)); + mode.finish(); + } else if (itemId == R.id.action_edit) { + startEditEntryActivity(_selectedEntries.get(0)); + mode.finish(); + } else if (itemId == R.id.action_toggle_favorite) { + for (VaultEntry entry : _selectedEntries) { + _vaultManager.getVault().editEntry(entry, newEntry -> { + newEntry.setIsFavorite(!newEntry.isFavorite()); + }); + } + + saveAndBackupVault(); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + mode.finish(); + } else if (itemId == R.id.action_share_qr) { + Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class); + ArrayList authInfos = new ArrayList<>(); + for (VaultEntry entry : _selectedEntries) { + GoogleAuthInfo authInfo = new GoogleAuthInfo(entry.getInfo(), entry.getName(), entry.getIssuer()); + authInfos.add(authInfo); + + _auditLogRepository.addEntrySharedEvent(entry.getUUID().toString()); + } + + intent.putExtra("authInfos", authInfos); + startActivity(intent); + + mode.finish(); + } else if (itemId == R.id.action_delete) { + Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { + for (VaultEntry entry : _selectedEntries) { + _vaultManager.getVault().removeEntry(entry); + } + saveAndBackupVault(); + _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + mode.finish(); + }); + } else if (itemId == R.id.action_select_all) { + _selectedEntries = _entryListView.selectAllEntries(); + setFavoriteMenuItemVisiblity(); + setIsMultipleSelected(_selectedEntries.size() > 1); + } else if (itemId == R.id.action_assign_icons) { + startAssignIconsActivity(_selectedEntries); + mode.finish(); + } else if (itemId == R.id.action_assign_groups) { + startAssignGroupsDialog(); + } else { + return false; + } + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + _entryListView.setActionModeState(false, null); + _actionModeBackPressHandler.setEnabled(false); + _selectedEntries.clear(); + _actionMode = null; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java new file mode 100644 index 0000000..8453e38 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java @@ -0,0 +1,53 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Intent; +import android.os.Bundle; +import android.widget.Toast; + +import com.beemdevelopment.aegis.BuildConfig; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.crypto.pins.GuardianProjectFDroidRSA2048; +import com.beemdevelopment.aegis.vault.VaultRepository; + +import info.guardianproject.GuardianProjectRSA4096; +import info.guardianproject.trustedintents.TrustedIntents; + +public class PanicResponderActivity extends AegisActivity { + public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (!_prefs.isPanicTriggerEnabled()) { + Toast.makeText(this, R.string.panic_trigger_ignore_toast, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + Intent intent; + if (!BuildConfig.TEST.get()) { + TrustedIntents trustedIntents = TrustedIntents.get(this); + trustedIntents.addTrustedSigner(GuardianProjectRSA4096.class); + trustedIntents.addTrustedSigner(GuardianProjectFDroidRSA2048.class); + + intent = trustedIntents.getIntentFromTrustedSender(this); + } else { + intent = getIntent(); + } + + if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { + VaultRepository.deleteFile(this); + _vaultManager.lock(false); + finishApp(); + return; + } + + finish(); + } + + private void finishApp() { + ExitActivity.exitAppAndRemoveFromRecents(this); + finishAndRemoveTask(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java new file mode 100644 index 0000000..1e7dff6 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java @@ -0,0 +1,124 @@ +package com.beemdevelopment.aegis.ui; + +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.ui.fragments.preferences.AppearancePreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.MainPreferencesFragment; +import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment; +import com.beemdevelopment.aegis.helpers.ViewHelper; + +public class PreferencesActivity extends AegisActivity implements + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { + private Fragment _fragment; + private CharSequence _prefTitle; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_preferences); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + getSupportFragmentManager() + .registerFragmentLifecycleCallbacks(new FragmentResumeListener(), true); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + if (savedInstanceState == null) { + _fragment = new MainPreferencesFragment(); + _fragment.setArguments(getIntent().getExtras()); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.content, _fragment) + .commit(); + + PreferencesFragment requestedFragment = getRequestedFragment(); + if (requestedFragment != null) { + _fragment = requestedFragment; + showFragment(_fragment); + } + } else { + _fragment = getSupportFragmentManager().findFragmentById(R.id.content); + _prefTitle = savedInstanceState.getCharSequence("prefTitle"); + if (_prefTitle != null) { + setTitle(_prefTitle); + } + } + } + + @Override + protected void onSaveInstanceState(@NonNull final Bundle outState) { + outState.putCharSequence("prefTitle", _prefTitle); + super.onSaveInstanceState(outState); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + getOnBackPressedDispatcher().onBackPressed(); + } else { + return super.onOptionsItemSelected(item); + } + + return true; + } + + @Override + public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat caller, Preference pref) { + _fragment = getSupportFragmentManager().getFragmentFactory().instantiate(getClassLoader(), pref.getFragment()); + _fragment.setArguments(pref.getExtras()); + _fragment.setTargetFragment(caller, 0); + showFragment(_fragment); + + _prefTitle = pref.getTitle(); + setTitle(_prefTitle); + return true; + } + + private void showFragment(Fragment fragment) { + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.slide_in_right, R.anim.slide_out_left, R.anim.slide_in_left, R.anim.slide_out_right) + .replace(R.id.content, fragment) + .addToBackStack(null) + .commit(); + } + + @SuppressWarnings("unchecked") + private PreferencesFragment getRequestedFragment() { + Class fragmentType = (Class) getIntent().getSerializableExtra("fragment"); + if (fragmentType == null) { + return null; + } + + try { + return fragmentType.newInstance(); + } catch (IllegalAccessException | InstantiationException e) { + throw new RuntimeException(e); + } + } + + private class FragmentResumeListener extends FragmentManager.FragmentLifecycleCallbacks { + @Override + public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) { + if (f instanceof MainPreferencesFragment) { + setTitle(R.string.action_settings); + } else if (f instanceof AppearancePreferencesFragment) { + _prefTitle = getString(R.string.pref_section_appearance_title); + setTitle(_prefTitle); + } + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java new file mode 100644 index 0000000..cc0d609 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java @@ -0,0 +1,242 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.camera.core.CameraInfoUnavailableException; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageAnalysis; +import androidx.camera.core.Preview; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.camera.view.PreviewView; +import androidx.core.content.ContextCompat; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.QrCodeAnalyzer; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.zxing.Result; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ScannerActivity extends AegisActivity implements QrCodeAnalyzer.Listener { + private ProcessCameraProvider _cameraProvider; + private ListenableFuture _cameraProviderFuture; + + private List _lenses; + private int _currentLens; + + private Menu _menu; + private ImageAnalysis _analysis; + private PreviewView _previewView; + private ExecutorService _executor; + + private int _batchId = 0; + private int _batchIndex = -1; + private List _entries; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_scanner); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + + _entries = new ArrayList<>(); + _lenses = new ArrayList<>(); + _previewView = findViewById(R.id.preview_view); + _executor = Executors.newSingleThreadExecutor(); + + _cameraProviderFuture = ProcessCameraProvider.getInstance(this); + _cameraProviderFuture.addListener(() -> { + try { + _cameraProvider = _cameraProviderFuture.get(); + } catch (ExecutionException | InterruptedException e) { + // if we're to believe the Android documentation, this should never happen + // https://developer.android.com/training/camerax/preview#check-provider + throw new RuntimeException(e); + } + + addCamera(CameraSelector.LENS_FACING_BACK); + addCamera(CameraSelector.LENS_FACING_FRONT); + if (_lenses.size() == 0) { + Toast.makeText(this, getString(R.string.no_cameras_available), Toast.LENGTH_LONG).show(); + finish(); + return; + } + _currentLens = _lenses.get(0); + updateCameraIcon(); + + bindPreview(_cameraProvider); + }, ContextCompat.getMainExecutor(this)); + } + + @Override + protected void onDestroy() { + if (_executor != null) { + _executor.shutdownNow(); + } + super.onDestroy(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + _menu = menu; + getMenuInflater().inflate(R.menu.menu_scanner, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (_cameraProvider == null) { + return false; + } + + if (item.getItemId() == R.id.action_camera) { + unbindPreview(_cameraProvider); + _currentLens = _currentLens == CameraSelector.LENS_FACING_BACK ? CameraSelector.LENS_FACING_FRONT : CameraSelector.LENS_FACING_BACK; + bindPreview(_cameraProvider); + updateCameraIcon(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void addCamera(int lens) { + try { + CameraSelector camera = new CameraSelector.Builder().requireLensFacing(lens).build(); + if (_cameraProvider.hasCamera(camera)) { + _lenses.add(lens); + } + } catch (CameraInfoUnavailableException e) { + e.printStackTrace(); + } + } + + private void updateCameraIcon() { + if (_menu != null) { + MenuItem item = _menu.findItem(R.id.action_camera); + boolean dual = _lenses.size() > 1; + if (dual) { + switch (_currentLens) { + case CameraSelector.LENS_FACING_BACK: + item.setIcon(R.drawable.ic_outline_camera_front_24); + break; + case CameraSelector.LENS_FACING_FRONT: + item.setIcon(R.drawable.ic_outline_camera_rear_24); + break; + } + } + item.setVisible(dual); + } + } + + private void bindPreview(@NonNull ProcessCameraProvider cameraProvider) { + Preview preview = new Preview.Builder().build(); + preview.setSurfaceProvider(_previewView.getSurfaceProvider()); + + CameraSelector selector = new CameraSelector.Builder() + .requireLensFacing(_currentLens) + .build(); + + _analysis = new ImageAnalysis.Builder() + .setTargetResolution(QrCodeAnalyzer.RESOLUTION) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build(); + _analysis.setAnalyzer(_executor, new QrCodeAnalyzer(this)); + + cameraProvider.bindToLifecycle(this, selector, preview, _analysis); + } + + private void unbindPreview(@NonNull ProcessCameraProvider cameraProvider) { + _analysis = null; + cameraProvider.unbindAll(); + } + + @Override + public void onQrCodeDetected(Result result) { + new Handler(getMainLooper()).post(() -> { + if (isFinishing()) { + return; + } + + if (_analysis != null) { + try { + Uri uri = Uri.parse(result.getText().trim()); + if (uri.getScheme() != null && uri.getScheme().equals(GoogleAuthInfo.SCHEME_EXPORT)) { + handleExportUri(uri); + } else { + handleUri(uri); + } + } catch (GoogleAuthInfoException e) { + e.printStackTrace(); + + unbindPreview(_cameraProvider); + + Dialogs.showErrorDialog(this, + e.isPhoneFactor() ? R.string.read_qr_error_phonefactor : R.string.read_qr_error, + e, ((dialog, which) -> bindPreview(_cameraProvider))); + } + } + }); + } + + private void handleUri(Uri uri) throws GoogleAuthInfoException { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(uri); + List entries = new ArrayList<>(); + entries.add(new VaultEntry(info)); + finish(entries); + } + + private void handleExportUri(Uri uri) throws GoogleAuthInfoException { + GoogleAuthInfo.Export export = GoogleAuthInfo.parseExportUri(uri); + + if (_batchId == 0) { + _batchId = export.getBatchId(); + } + + int batchIndex = export.getBatchIndex(); + if (_batchId != export.getBatchId()) { + Toast.makeText(this, R.string.google_qr_export_unrelated, Toast.LENGTH_SHORT).show(); + } else if (_batchIndex == -1 || _batchIndex == batchIndex - 1) { + for (GoogleAuthInfo info : export.getEntries()) { + VaultEntry entry = new VaultEntry(info); + _entries.add(entry); + } + + _batchIndex = batchIndex; + if (_batchIndex + 1 == export.getBatchSize()) { + finish(_entries); + } + + Toast.makeText(this, getResources().getQuantityString(R.plurals.google_qr_export_scanned, export.getBatchSize(), _batchIndex + 1, export.getBatchSize()), Toast.LENGTH_SHORT).show(); + } else if (_batchIndex != batchIndex) { + Toast.makeText(this, getString(R.string.google_qr_export_unexpected, _batchIndex + 1, batchIndex + 1), Toast.LENGTH_SHORT).show(); + } + } + + private void finish(List entries) { + Intent intent = new Intent(); + intent.putExtra("entries", (ArrayList) entries); + setResult(RESULT_OK, intent); + finish(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java new file mode 100644 index 0000000..a0559d7 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java @@ -0,0 +1,221 @@ +package com.beemdevelopment.aegis.ui; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.os.PersistableBundle; +import android.provider.Settings; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.constraintlayout.widget.ConstraintLayout; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.Theme; +import com.beemdevelopment.aegis.helpers.QrCodeHelper; +import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; +import com.beemdevelopment.aegis.otp.Transferable; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.imageview.ShapeableImageView; +import com.google.zxing.WriterException; + +import java.util.ArrayList; +import java.util.List; + +public class TransferEntriesActivity extends AegisActivity { + private List _authInfos; + private ShapeableImageView _qrImage; + private TextView _description; + private TextView _issuer; + private TextView _accountName; + private TextView _entriesCount; + private Button _nextButton; + private Button _previousButton; + private Button _copyButton; + private int _currentEntryCount = 1; + private float _deviceBrightness; + private boolean _isMaxBrightnessSet = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (abortIfOrphan(savedInstanceState)) { + return; + } + setContentView(R.layout.activity_share_entry); + setSupportActionBar(findViewById(R.id.toolbar)); + ViewHelper.setupAppBarInsets(findViewById(R.id.app_bar_layout)); + + _qrImage = findViewById(R.id.ivQrCode); + _description = findViewById(R.id.tvDescription); + _issuer = findViewById(R.id.tvIssuer); + _accountName = findViewById(R.id.tvAccountName); + _entriesCount = findViewById(R.id.tvEntriesCount); + _nextButton = findViewById(R.id.btnNext); + _previousButton = findViewById(R.id.btnPrevious); + _copyButton = findViewById(R.id.btnCopyClipboard); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + + Intent intent = getIntent(); + _authInfos = (ArrayList) intent.getSerializableExtra("authInfos"); + + int controlVisibility = _authInfos.size() != 1 ? View.VISIBLE : View.INVISIBLE; + _nextButton.setVisibility(controlVisibility); + + _nextButton.setOnClickListener(v -> { + if (_currentEntryCount < _authInfos.size()) { + _previousButton.setVisibility(View.VISIBLE); + _currentEntryCount++; + generateQR(); + + if (_currentEntryCount == _authInfos.size()) { + _nextButton.setText(R.string.done); + } + } else { + finish(); + } + }); + + _previousButton.setOnClickListener(v -> { + if (_currentEntryCount > 1) { + _nextButton.setText(R.string.next); + _currentEntryCount--; + generateQR(); + + if (_currentEntryCount == 1) { + _previousButton.setVisibility(View.INVISIBLE); + } + } + }); + + if (_authInfos.get(0) instanceof GoogleAuthInfo) { + _copyButton.setVisibility(View.VISIBLE); + } + + _copyButton.setOnClickListener(v -> { + Transferable selectedEntry = _authInfos.get(_currentEntryCount - 1); + try { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("text/plain", selectedEntry.getUri().toString()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PersistableBundle extras = new PersistableBundle(); + extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + clip.getDescription().setExtras(extras); + } + if (clipboard != null) { + clipboard.setPrimaryClip(clip); + } + Toast.makeText(this, R.string.uri_copied_to_clipboard, Toast.LENGTH_SHORT).show(); + + } catch (GoogleAuthInfoException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_copy_uri_to_clipboard, e); + } + }); + + // Calculate sensible dimensions for the QR code depending on whether we're in landscape + _qrImage.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + ConstraintLayout layout = findViewById(R.id.layoutShareEntry); + if (layout.getWidth() > layout.getHeight()) { + int squareSize = (int) (0.5 * layout.getHeight()); + ViewGroup.LayoutParams params = _qrImage.getLayoutParams(); + params.width = squareSize; + params.height = squareSize; + _qrImage.setLayoutParams(params); + } + + generateQR(); + + _qrImage.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + }); + + _deviceBrightness = getSystemBrightness(); + _qrImage.setOnClickListener(v -> { + if (!_isMaxBrightnessSet) { + setBrightness(1f); + _isMaxBrightnessSet = true; + } else { + setBrightness(_deviceBrightness); + _isMaxBrightnessSet = false; + } + }); + } + + private float getSystemBrightness() { + int brightness = 0; + try { + brightness = Settings.System.getInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS); + } catch (Settings.SettingNotFoundException e) { + e.printStackTrace(); + } + + return brightness / 255f; + } + + private void setBrightness(float brightnessAmount) { + WindowManager.LayoutParams attrs = getWindow().getAttributes(); + attrs.screenBrightness = brightnessAmount; + getWindow().setAttributes(attrs); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + break; + default: + return super.onOptionsItemSelected(item); + } + + return true; + } + + private void generateQR() { + Transferable selectedEntry = _authInfos.get(_currentEntryCount - 1); + if (selectedEntry instanceof GoogleAuthInfo) { + GoogleAuthInfo entry = (GoogleAuthInfo) selectedEntry; + _issuer.setText(entry.getIssuer()); + _accountName.setText(entry.getAccountName()); + } else if (selectedEntry instanceof GoogleAuthInfo.Export) { + _description.setText(R.string.google_auth_compatible_transfer_description); + } + + _entriesCount.setText(getResources().getQuantityString(R.plurals.qr_count, _authInfos.size(), _currentEntryCount, _authInfos.size())); + + int backgroundColor = _themeHelper.getConfiguredTheme() == Theme.LIGHT + ? MaterialColors.getColor(_qrImage, com.google.android.material.R.attr.colorSurfaceContainer) + : Color.WHITE; + + Bitmap bitmap; + try { + bitmap = QrCodeHelper.encodeToBitmap(selectedEntry.getUri().toString(), _qrImage.getWidth(), _qrImage.getWidth(), backgroundColor); + } catch (WriterException | GoogleAuthInfoException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_generate_qrcode, e); + return; + } + + _qrImage.setImageBitmap(bitmap); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java b/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java new file mode 100644 index 0000000..0624998 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java @@ -0,0 +1,182 @@ +package com.beemdevelopment.aegis.ui.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.text.InputType; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.CheckBox; +import android.widget.Filter; +import android.widget.Filterable; + +import androidx.annotation.PluralsRes; +import androidx.appcompat.widget.AppCompatAutoCompleteTextView; + +import com.beemdevelopment.aegis.R; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { + private @PluralsRes int _selectedCountPlural = R.plurals.dropdown_checkboxes_default_count; + + private boolean _allowFiltering = false; + + private final List _items = new ArrayList<>(); + private List _visibleItems = new ArrayList<>(); + private final Set _checkedItems = new HashSet<>(); + + private CheckboxAdapter _adapter; + + public DropdownCheckBoxes(Context context) { + super(context); + initialise(context, null); + } + + public DropdownCheckBoxes(Context context, AttributeSet attrs) { + super(context, attrs); + initialise(context, attrs); + } + + public DropdownCheckBoxes(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialise(context, attrs); + } + + private void initialise(Context context, AttributeSet attrs) { + _adapter = new CheckboxAdapter(); + setAdapter(_adapter); + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes( + attrs, + R.styleable.DropdownCheckBoxes, + 0, 0); + + _allowFiltering = a.getBoolean(R.styleable.DropdownCheckBoxes_allow_filtering, false); + a.recycle(); + } + + if (!_allowFiltering) { + setInputType(0); + } else { + setInputType(InputType.TYPE_CLASS_TEXT); + } + } + + /** + * Add parameterized items to be displayed as a checkbox in the dropdown view + * the label for the checkbox is determined by the toString() method of the items + * you add. + * + * @param items a list of the items you want to show in the dropdown + * @param startChecked whether the checkbox should be checked initially + */ + public void addItems(List items, boolean startChecked) { + _items.addAll(items); + _visibleItems.addAll(items); + + if (startChecked) { + _checkedItems.addAll(items); + } + + updateCheckedItemsCountText(); + _adapter.notifyDataSetChanged(); + } + + private void updateCheckedItemsCountText() { + if (_allowFiltering) { + return; + } + + int count = _checkedItems.size(); + String countString = getResources().getQuantityString(_selectedCountPlural, count, count); + + setText(countString, false); + } + + public void setCheckedItemsCountTextRes(@PluralsRes int resId) { + _selectedCountPlural = resId; + } + + public Set getCheckedItems() { + return _checkedItems; + } + + private class CheckboxAdapter extends BaseAdapter implements Filterable { + + @Override + public int getCount() { + return _visibleItems.size(); + } + + @Override + public T getItem(int i) { + return _visibleItems.get(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(int i, View convertView, ViewGroup viewGroup) { + if (convertView == null) { + convertView = LayoutInflater.from(getContext()).inflate(R.layout.dropdown_checkbox, viewGroup, false); + } + + T item = _visibleItems.get(i); + + CheckBox checkBox = convertView.findViewById(R.id.checkbox_in_dropdown); + checkBox.setText(item.toString()); + checkBox.setTag(item); + checkBox.setChecked(_checkedItems.contains(item)); + + checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + _checkedItems.add((T) buttonView.getTag()); + } else { + _checkedItems.remove((T) buttonView.getTag()); + } + + updateCheckedItemsCountText(); + }); + + return convertView; + } + + @Override + public Filter getFilter() { + return new Filter() { + @Override + protected FilterResults performFiltering(CharSequence query) { + FilterResults results = new FilterResults(); + results.values = (query == null || query.toString().isEmpty()) + ? _items + : _items.stream().filter(item -> { + String q = query.toString().toLowerCase(); + String strLower = item.toString().toLowerCase(); + + return strLower.contains(q); + }) + .collect(Collectors.toList()); + + return results; + } + + @Override + protected void publishResults(CharSequence charSequence, FilterResults filterResults) { + _visibleItems = (List) filterResults.values; + notifyDataSetChanged(); + } + }; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java b/app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java new file mode 100644 index 0000000..818981d --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java @@ -0,0 +1,35 @@ +package com.beemdevelopment.aegis.ui.components; + +import android.content.Context; +import android.os.Build; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.textfield.TextInputEditText; + +public class NoAutofillEditText extends TextInputEditText { + + public NoAutofillEditText(@NonNull Context context) { + super(context); + } + + public NoAutofillEditText(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public NoAutofillEditText(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public int getAutofillType() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return View.AUTOFILL_TYPE_NONE; + } else { + return super.getAutofillType(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java new file mode 100644 index 0000000..d26d24b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java @@ -0,0 +1,21 @@ +package com.beemdevelopment.aegis.ui.dialogs; + +import android.content.Context; + +import com.beemdevelopment.aegis.R; + +public class ChangelogDialog extends SimpleWebViewDialog { + public ChangelogDialog() { + super(R.string.changelog); + } + + public static ChangelogDialog create() { + return new ChangelogDialog(); + } + + @Override + protected String getContent(Context context) { + String content = readAssetAsString(context, "changelog.html"); + return String.format(content, getBackgroundColor(), getTextColor()); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java new file mode 100644 index 0000000..692ba3a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java @@ -0,0 +1,663 @@ +package com.beemdevelopment.aegis.ui.dialogs; + +import android.app.Dialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.text.InputType; +import android.text.SpannableStringBuilder; +import android.text.TextWatcher; +import android.text.method.PasswordTransformationMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.NumberPicker; +import android.widget.ProgressBar; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.ComponentActivity; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; + +import com.beemdevelopment.aegis.BackupsVersioningStrategy; +import com.beemdevelopment.aegis.Preferences; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.EditTextHelper; +import com.beemdevelopment.aegis.helpers.PasswordStrengthHelper; +import com.beemdevelopment.aegis.helpers.SimpleTextWatcher; +import com.beemdevelopment.aegis.importers.DatabaseImporter; +import com.beemdevelopment.aegis.ui.tasks.KeyDerivationTask; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; +import com.beemdevelopment.aegis.vault.slots.Slot; +import com.beemdevelopment.aegis.vault.slots.SlotException; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import javax.crypto.Cipher; + +public class Dialogs { + private Dialogs() { + + } + + public static void secureDialog(Dialog dialog) { + if (new Preferences(dialog.getContext()).isSecureScreenEnabled()) { + dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + } + } + + public static void showSecureDialog(Dialog dialog) { + secureDialog(dialog); + dialog.show(); + } + + public static void showDeleteEntriesDialog(Context context, List services, DialogInterface.OnClickListener onDelete) { + View view = LayoutInflater.from(context).inflate(R.layout.dialog_delete_entry, null); + TextView textMessage = view.findViewById(R.id.text_message); + TextView textExplanation = view.findViewById(R.id.text_explanation); + String entries = services.stream() + .map(entry -> String.format("• %s", getVaultEntryName(context, entry))) + .collect(Collectors.joining("\n")); + textExplanation.setText(context.getString(R.string.delete_entry_explanation, entries)); + + String title, message; + if (services.size() > 1) { + title = context.getString(R.string.delete_entries); + message = context.getResources().getQuantityString(R.plurals.delete_entries_description, services.size(), services.size()); + } else { + title = context.getString(R.string.delete_entry); + message = context.getString(R.string.delete_entry_description); + } + textMessage.setText(message); + + showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(title) + .setView(view) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(android.R.string.yes, onDelete) + .setNegativeButton(android.R.string.no, null) + .create()); + } + + private static String getVaultEntryName(Context context, VaultEntry entry) { + if (!entry.getIssuer().isEmpty() && !entry.getName().isEmpty()) { + return String.format("%s (%s)", entry.getIssuer(), entry.getName()); + } else if (entry.getIssuer().isEmpty() && entry.getName().isEmpty()) { + return context.getString(R.string.unknown_issuer); + } else if (entry.getIssuer().isEmpty()) { + return entry.getName(); + } else { + return entry.getIssuer(); + } + } + + public static void showDiscardDialog(Context context, DialogInterface.OnClickListener onSave, DialogInterface.OnClickListener onDiscard) { + showSecureDialog(new MaterialAlertDialogBuilder(context, R.style.ThemeOverlay_Aegis_AlertDialog_Warning) + .setTitle(context.getString(R.string.discard_changes)) + .setMessage(context.getString(R.string.discard_changes_description)) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setPositiveButton(R.string.save, onSave) + .setNegativeButton(R.string.discard, onDiscard) + .create()); + } + + public static void showSetPasswordDialog(ComponentActivity activity, PasswordSlotListener listener) { + View view = activity.getLayoutInflater().inflate(R.layout.dialog_password, null); + EditText textPassword = view.findViewById(R.id.text_password); + EditText textPasswordConfirm = view.findViewById(R.id.text_password_confirm); + ProgressBar barPasswordStrength = view.findViewById(R.id.progressBar); + TextView textPasswordStrength = view.findViewById(R.id.text_password_strength); + TextInputLayout textPasswordWrapper = view.findViewById(R.id.text_password_wrapper); + CheckBox switchToggleVisibility = view.findViewById(R.id.check_toggle_visibility); + PasswordStrengthHelper passStrength = new PasswordStrengthHelper( + textPassword, barPasswordStrength, textPasswordStrength, textPasswordWrapper); + + switchToggleVisibility.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + textPassword.setTransformationMethod(null); + textPasswordConfirm.setTransformationMethod(null); + textPassword.clearFocus(); + textPasswordConfirm.clearFocus(); + } else { + textPassword.setTransformationMethod(new PasswordTransformationMethod()); + textPasswordConfirm.setTransformationMethod(new PasswordTransformationMethod()); + } + }); + + AlertDialog dialog = new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.set_password) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + + final AtomicReference