Repository: beemdevelopment/Aegis Branch: master Commit: 59d5c640d6a2 Files: 579 Total size: 3.8 MB Directory structure: gitextract_rgq5wf8x/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ └── feature.md │ └── workflows/ │ ├── build-app-workflow.yaml │ ├── codeql-analysis.yml │ └── crowdin.yml ├── .gitignore ├── CONTRIBUTING.md ├── FAQ.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── config/ │ │ ├── libraries/ │ │ │ ├── krop.json │ │ │ ├── libsu.json │ │ │ ├── textdrawable.json │ │ │ └── trustedintents.json │ │ └── licenses/ │ │ └── 3ca920d1875f7ad7ab04a2a331958577.json │ ├── lint.xml │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.beemdevelopment.aegis.database.AppDatabase/ │ │ └── 1.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── beemdevelopment/ │ │ └── aegis/ │ │ ├── AegisTest.java │ │ ├── AegisTestApplication.java │ │ ├── AegisTestRunner.java │ │ ├── BackupExportTest.java │ │ ├── DeepLinkTest.java │ │ ├── EmptySecretTest.java │ │ ├── IntroTest.java │ │ ├── OverallTest.java │ │ ├── PanicTriggerTest.java │ │ ├── rules/ │ │ │ └── ScreenshotTestRule.java │ │ └── vault/ │ │ └── VaultRepositoryTest.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ ├── changelog.html │ │ │ └── license.html │ │ ├── java/ │ │ │ ├── com/ │ │ │ │ ├── amulyakhare/ │ │ │ │ │ └── textdrawable/ │ │ │ │ │ ├── LICENSE │ │ │ │ │ ├── TextDrawable.java │ │ │ │ │ └── util/ │ │ │ │ │ └── ColorGenerator.java │ │ │ │ └── beemdevelopment/ │ │ │ │ └── aegis/ │ │ │ │ ├── AccountNamePosition.java │ │ │ │ ├── AegisApplication.java │ │ │ │ ├── AegisApplicationBase.java │ │ │ │ ├── AegisBackupAgent.java │ │ │ │ ├── AegisModule.java │ │ │ │ ├── BackupsVersioningStrategy.java │ │ │ │ ├── CopyBehavior.java │ │ │ │ ├── EventType.java │ │ │ │ ├── GroupPlaceholderType.java │ │ │ │ ├── PassReminderFreq.java │ │ │ │ ├── Preferences.java │ │ │ │ ├── SortCategory.java │ │ │ │ ├── Theme.java │ │ │ │ ├── ThemeMap.java │ │ │ │ ├── VibrationPatterns.java │ │ │ │ ├── ViewMode.java │ │ │ │ ├── crypto/ │ │ │ │ │ ├── CryptParameters.java │ │ │ │ │ ├── CryptResult.java │ │ │ │ │ ├── CryptoUtils.java │ │ │ │ │ ├── KeyStoreHandle.java │ │ │ │ │ ├── KeyStoreHandleException.java │ │ │ │ │ ├── MasterKey.java │ │ │ │ │ ├── MasterKeyException.java │ │ │ │ │ ├── SCryptParameters.java │ │ │ │ │ ├── bc/ │ │ │ │ │ │ ├── SCrypt.java │ │ │ │ │ │ └── Salsa20Engine.java │ │ │ │ │ ├── otp/ │ │ │ │ │ │ ├── HOTP.java │ │ │ │ │ │ ├── MOTP.java │ │ │ │ │ │ ├── OTP.java │ │ │ │ │ │ ├── TOTP.java │ │ │ │ │ │ └── YAOTP.java │ │ │ │ │ └── pins/ │ │ │ │ │ └── GuardianProjectFDroidRSA2048.java │ │ │ │ ├── database/ │ │ │ │ │ ├── AppDatabase.java │ │ │ │ │ ├── AuditLogDao.java │ │ │ │ │ ├── AuditLogEntry.java │ │ │ │ │ └── AuditLogRepository.java │ │ │ │ ├── encoding/ │ │ │ │ │ ├── Base32.java │ │ │ │ │ ├── Base64.java │ │ │ │ │ ├── EncodingException.java │ │ │ │ │ └── Hex.java │ │ │ │ ├── helpers/ │ │ │ │ │ ├── AnimationsHelper.java │ │ │ │ │ ├── BiometricSlotInitializer.java │ │ │ │ │ ├── BiometricsHelper.java │ │ │ │ │ ├── BitmapHelper.java │ │ │ │ │ ├── CenterVerticalSpan.java │ │ │ │ │ ├── ContextHelper.java │ │ │ │ │ ├── DropdownHelper.java │ │ │ │ │ ├── EditTextHelper.java │ │ │ │ │ ├── FabMenuHelper.java │ │ │ │ │ ├── FabScrollHelper.java │ │ │ │ │ ├── ItemTouchHelperAdapter.java │ │ │ │ │ ├── MetricsHelper.java │ │ │ │ │ ├── PasswordStrengthHelper.java │ │ │ │ │ ├── PermissionHelper.java │ │ │ │ │ ├── QrCodeAnalyzer.java │ │ │ │ │ ├── QrCodeHelper.java │ │ │ │ │ ├── SafHelper.java │ │ │ │ │ ├── SimpleAnimationEndListener.java │ │ │ │ │ ├── SimpleItemTouchHelperCallback.java │ │ │ │ │ ├── SimpleTextWatcher.java │ │ │ │ │ ├── TextDrawableHelper.java │ │ │ │ │ ├── ThemeHelper.java │ │ │ │ │ ├── UiRefresher.java │ │ │ │ │ ├── UiThreadExecutor.java │ │ │ │ │ ├── VibrationHelper.java │ │ │ │ │ ├── ViewHelper.java │ │ │ │ │ └── comparators/ │ │ │ │ │ ├── AccountNameComparator.java │ │ │ │ │ ├── FavoriteComparator.java │ │ │ │ │ ├── IssuerNameComparator.java │ │ │ │ │ ├── LastUsedComparator.java │ │ │ │ │ └── UsageCountComparator.java │ │ │ │ ├── icons/ │ │ │ │ │ ├── IconPack.java │ │ │ │ │ ├── IconPackException.java │ │ │ │ │ ├── IconPackExistsException.java │ │ │ │ │ ├── IconPackManager.java │ │ │ │ │ └── IconType.java │ │ │ │ ├── importers/ │ │ │ │ │ ├── AegisImporter.java │ │ │ │ │ ├── AndOtpImporter.java │ │ │ │ │ ├── AuthenticatorPlusImporter.java │ │ │ │ │ ├── AuthyImporter.java │ │ │ │ │ ├── BattleNetImporter.java │ │ │ │ │ ├── BitwardenImporter.java │ │ │ │ │ ├── DatabaseImporter.java │ │ │ │ │ ├── DatabaseImporterEntryException.java │ │ │ │ │ ├── DatabaseImporterException.java │ │ │ │ │ ├── DuoImporter.java │ │ │ │ │ ├── EnteAuthImporter.java │ │ │ │ │ ├── FreeOtpImporter.java │ │ │ │ │ ├── FreeOtpPlusImporter.java │ │ │ │ │ ├── GoogleAuthImporter.java │ │ │ │ │ ├── GoogleAuthUriImporter.java │ │ │ │ │ ├── MicrosoftAuthImporter.java │ │ │ │ │ ├── ProtonAuthenticatorImporter.java │ │ │ │ │ ├── SqlImporterHelper.java │ │ │ │ │ ├── SteamImporter.java │ │ │ │ │ ├── StratumImporter.java │ │ │ │ │ ├── TotpAuthenticatorImporter.java │ │ │ │ │ ├── TwoFASImporter.java │ │ │ │ │ └── WinAuthImporter.java │ │ │ │ ├── otp/ │ │ │ │ │ ├── GoogleAuthInfo.java │ │ │ │ │ ├── GoogleAuthInfoException.java │ │ │ │ │ ├── HotpInfo.java │ │ │ │ │ ├── MotpInfo.java │ │ │ │ │ ├── OtpInfo.java │ │ │ │ │ ├── OtpInfoException.java │ │ │ │ │ ├── SteamInfo.java │ │ │ │ │ ├── TotpInfo.java │ │ │ │ │ ├── Transferable.java │ │ │ │ │ └── YandexInfo.java │ │ │ │ ├── receivers/ │ │ │ │ │ ├── QsTileRefreshReceiver.java │ │ │ │ │ └── VaultLockReceiver.java │ │ │ │ ├── services/ │ │ │ │ │ ├── LaunchAppTileService.java │ │ │ │ │ ├── LaunchScannerTileService.java │ │ │ │ │ └── NotificationService.java │ │ │ │ ├── ui/ │ │ │ │ │ ├── AboutActivity.java │ │ │ │ │ ├── AegisActivity.java │ │ │ │ │ ├── AssignIconsActivity.java │ │ │ │ │ ├── AuthActivity.java │ │ │ │ │ ├── EditEntryActivity.java │ │ │ │ │ ├── ExitActivity.java │ │ │ │ │ ├── GroupManagerActivity.java │ │ │ │ │ ├── ImportEntriesActivity.java │ │ │ │ │ ├── IntroActivity.java │ │ │ │ │ ├── LicensesActivity.java │ │ │ │ │ ├── MainActivity.java │ │ │ │ │ ├── PanicResponderActivity.java │ │ │ │ │ ├── PreferencesActivity.java │ │ │ │ │ ├── ScannerActivity.java │ │ │ │ │ ├── TransferEntriesActivity.java │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── DropdownCheckBoxes.java │ │ │ │ │ │ └── NoAutofillEditText.java │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ ├── ChangelogDialog.java │ │ │ │ │ │ ├── Dialogs.java │ │ │ │ │ │ ├── IconPickerDialog.java │ │ │ │ │ │ ├── LicenseDialog.java │ │ │ │ │ │ └── SimpleWebViewDialog.java │ │ │ │ │ ├── fragments/ │ │ │ │ │ │ └── preferences/ │ │ │ │ │ │ ├── AppearancePreferencesFragment.java │ │ │ │ │ │ ├── AuditLogPreferencesFragment.java │ │ │ │ │ │ ├── BackupsPreferencesFragment.java │ │ │ │ │ │ ├── BehaviorPreferencesFragment.java │ │ │ │ │ │ ├── IconPacksManagerFragment.java │ │ │ │ │ │ ├── ImportExportPreferencesFragment.java │ │ │ │ │ │ ├── MainPreferencesFragment.java │ │ │ │ │ │ ├── PreferencesFragment.java │ │ │ │ │ │ └── SecurityPreferencesFragment.java │ │ │ │ │ ├── glide/ │ │ │ │ │ │ ├── AegisGlideModule.java │ │ │ │ │ │ ├── GlideHelper.java │ │ │ │ │ │ ├── SvgBytesDecoder.java │ │ │ │ │ │ ├── SvgDecoder.java │ │ │ │ │ │ ├── SvgDrawableTranscoder.java │ │ │ │ │ │ ├── VaultEntryIconKey.java │ │ │ │ │ │ └── VaultEntryIconLoader.java │ │ │ │ │ ├── intro/ │ │ │ │ │ │ ├── IntroActivityInterface.java │ │ │ │ │ │ ├── IntroBaseActivity.java │ │ │ │ │ │ ├── SlideFragment.java │ │ │ │ │ │ └── SlideIndicator.java │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── AssignIconEntry.java │ │ │ │ │ │ ├── AuditLogEntryModel.java │ │ │ │ │ │ ├── ErrorCardInfo.java │ │ │ │ │ │ ├── ImportEntry.java │ │ │ │ │ │ └── VaultGroupModel.java │ │ │ │ │ ├── preferences/ │ │ │ │ │ │ └── SwitchPreference.java │ │ │ │ │ ├── slides/ │ │ │ │ │ │ ├── DoneSlide.java │ │ │ │ │ │ ├── SecurityPickerSlide.java │ │ │ │ │ │ ├── SecuritySetupSlide.java │ │ │ │ │ │ └── WelcomeSlide.java │ │ │ │ │ ├── tasks/ │ │ │ │ │ │ ├── Argon2Task.java │ │ │ │ │ │ ├── ExportTask.java │ │ │ │ │ │ ├── IconOptimizationTask.java │ │ │ │ │ │ ├── ImportFileTask.java │ │ │ │ │ │ ├── ImportIconPackTask.java │ │ │ │ │ │ ├── KeyDerivationTask.java │ │ │ │ │ │ ├── PBKDFTask.java │ │ │ │ │ │ ├── PasswordSlotDecryptTask.java │ │ │ │ │ │ ├── ProgressDialogTask.java │ │ │ │ │ │ ├── QrDecodeTask.java │ │ │ │ │ │ └── RootShellTask.java │ │ │ │ │ └── views/ │ │ │ │ │ ├── AssignIconAdapter.java │ │ │ │ │ ├── AssignIconHolder.java │ │ │ │ │ ├── AuditLogAdapter.java │ │ │ │ │ ├── AuditLogHolder.java │ │ │ │ │ ├── EntryAdapter.java │ │ │ │ │ ├── EntryHolder.java │ │ │ │ │ ├── EntryListView.java │ │ │ │ │ ├── ErrorCardHolder.java │ │ │ │ │ ├── GroupAdapter.java │ │ │ │ │ ├── GroupHolder.java │ │ │ │ │ ├── IconAdapter.java │ │ │ │ │ ├── IconCategoryHolder.java │ │ │ │ │ ├── IconHolder.java │ │ │ │ │ ├── IconPackAdapter.java │ │ │ │ │ ├── IconPackHolder.java │ │ │ │ │ ├── IconRecyclerView.java │ │ │ │ │ ├── ImportEntriesAdapter.java │ │ │ │ │ ├── ImportEntryHolder.java │ │ │ │ │ └── TotpProgressBar.java │ │ │ │ ├── util/ │ │ │ │ │ ├── ClipboardUtils.java │ │ │ │ │ ├── Cloner.java │ │ │ │ │ ├── CollectionUtils.java │ │ │ │ │ ├── IOUtils.java │ │ │ │ │ ├── JsonUtils.java │ │ │ │ │ ├── PreferenceParser.java │ │ │ │ │ ├── TimeUtils.java │ │ │ │ │ └── UUIDMap.java │ │ │ │ └── vault/ │ │ │ │ ├── Vault.java │ │ │ │ ├── VaultBackupManager.java │ │ │ │ ├── VaultBackupPermissionException.java │ │ │ │ ├── VaultEntry.java │ │ │ │ ├── VaultEntryException.java │ │ │ │ ├── VaultEntryIcon.java │ │ │ │ ├── VaultEntryIconException.java │ │ │ │ ├── VaultException.java │ │ │ │ ├── VaultFile.java │ │ │ │ ├── VaultFileCredentials.java │ │ │ │ ├── VaultFileException.java │ │ │ │ ├── VaultGroup.java │ │ │ │ ├── VaultHtmlExporter.java │ │ │ │ ├── VaultManager.java │ │ │ │ ├── VaultRepository.java │ │ │ │ ├── VaultRepositoryException.java │ │ │ │ └── slots/ │ │ │ │ ├── BiometricSlot.java │ │ │ │ ├── PasswordSlot.java │ │ │ │ ├── RawSlot.java │ │ │ │ ├── Slot.java │ │ │ │ ├── SlotException.java │ │ │ │ ├── SlotIntegrityException.java │ │ │ │ ├── SlotList.java │ │ │ │ └── SlotListException.java │ │ │ └── info/ │ │ │ └── guardianproject/ │ │ │ ├── GuardianProjectRSA4096.java │ │ │ └── trustedintents/ │ │ │ ├── ApkSignaturePin.java │ │ │ ├── LICENSE.txt │ │ │ └── TrustedIntents.java │ │ ├── proto/ │ │ │ └── google_auth.proto │ │ └── res/ │ │ ├── anim/ │ │ │ ├── fade_in.xml │ │ │ ├── fade_out.xml │ │ │ ├── item_animation_fall_down.xml │ │ │ ├── item_scale_in.xml │ │ │ ├── item_scale_out.xml │ │ │ ├── layout_animation_fall_down.xml │ │ │ ├── slide_down_fade_in.xml │ │ │ ├── slide_down_fade_out.xml │ │ │ ├── slide_in_left.xml │ │ │ ├── slide_in_right.xml │ │ │ ├── slide_out_left.xml │ │ │ └── slide_out_right.xml │ │ ├── drawable/ │ │ │ ├── baseline_arrow_right_24.xml │ │ │ ├── favorite_indicator.xml │ │ │ ├── ic_aegis_notification.xml │ │ │ ├── ic_aegis_quicksettings.xml │ │ │ ├── ic_counter_black_24.xml │ │ │ ├── ic_export_notes.xml │ │ │ ├── ic_filled_star_24.xml │ │ │ ├── ic_folder_zip.xml │ │ │ ├── ic_lock.xml │ │ │ ├── ic_lock_open.xml │ │ │ ├── ic_outline_add_24.xml │ │ │ ├── ic_outline_add_photo_alternate_24.xml │ │ │ ├── ic_outline_android_24.xml │ │ │ ├── ic_outline_arrow_left_alt_24.xml │ │ │ ├── ic_outline_arrow_right_alt_24.xml │ │ │ ├── ic_outline_brush_24.xml │ │ │ ├── ic_outline_camera_front_24.xml │ │ │ ├── ic_outline_camera_rear_24.xml │ │ │ ├── ic_outline_check_24.xml │ │ │ ├── ic_outline_close_24.xml │ │ │ ├── ic_outline_cloud_upload_24.xml │ │ │ ├── ic_outline_code_24.xml │ │ │ ├── ic_outline_construction_24.xml │ │ │ ├── ic_outline_content_copy_24.xml │ │ │ ├── ic_outline_delete_24.xml │ │ │ ├── ic_outline_description_24.xml │ │ │ ├── ic_outline_done_all_24.xml │ │ │ ├── ic_outline_edit_24.xml │ │ │ ├── ic_outline_error_24.xml │ │ │ ├── ic_outline_expand_more_24.xml │ │ │ ├── ic_outline_fiber_pin_24.xml │ │ │ ├── ic_outline_group_24.xml │ │ │ ├── ic_outline_history_24.xml │ │ │ ├── ic_outline_info_24.xml │ │ │ ├── ic_outline_key_24.xml │ │ │ ├── ic_outline_layers_24.xml │ │ │ ├── ic_outline_lock_24.xml │ │ │ ├── ic_outline_mail_24.xml │ │ │ ├── ic_outline_menu_24.xml │ │ │ ├── ic_outline_more_vert_24.xml │ │ │ ├── ic_outline_notes_24.xml │ │ │ ├── ic_outline_package_variant_24.xml │ │ │ ├── ic_outline_person_24.xml │ │ │ ├── ic_outline_public_24.xml │ │ │ ├── ic_outline_qr_code_2_24.xml │ │ │ ├── ic_outline_refresh_24.xml │ │ │ ├── ic_outline_reset_image_24.xml │ │ │ ├── ic_outline_sort_24.xml │ │ │ ├── ic_outline_star_24.xml │ │ │ ├── ic_outline_touch_app_24.xml │ │ │ ├── ic_outline_warning_24.xml │ │ │ ├── ic_qrcode_scan.xml │ │ │ ├── ic_share.xml │ │ │ ├── ic_tag_24.xml │ │ │ ├── ic_timeline_24.xml │ │ │ ├── ic_unselected.xml │ │ │ ├── item_selected.xml │ │ │ ├── progress_horizontal.xml │ │ │ └── rounded_popup.xml │ │ ├── layout/ │ │ │ ├── activity_about.xml │ │ │ ├── activity_assign_icons.xml │ │ │ ├── activity_auth.xml │ │ │ ├── activity_edit_entry.xml │ │ │ ├── activity_groups.xml │ │ │ ├── activity_import_entries.xml │ │ │ ├── activity_intro.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_preferences.xml │ │ │ ├── activity_scanner.xml │ │ │ ├── activity_share_entry.xml │ │ │ ├── card_assign_icon_entry.xml │ │ │ ├── card_audit_log.xml │ │ │ ├── card_entry.xml │ │ │ ├── card_entry_compact.xml │ │ │ ├── card_entry_small.xml │ │ │ ├── card_entry_tile.xml │ │ │ ├── card_error.xml │ │ │ ├── card_footer.xml │ │ │ ├── card_group.xml │ │ │ ├── card_icon.xml │ │ │ ├── card_icon_category.xml │ │ │ ├── card_icon_pack.xml │ │ │ ├── card_import_entry.xml │ │ │ ├── card_importer.xml │ │ │ ├── chip_group_filter.xml │ │ │ ├── content_about.xml │ │ │ ├── dialog_add_entry.xml │ │ │ ├── dialog_backups_versioning_strategy.xml │ │ │ ├── dialog_checkbox.xml │ │ │ ├── dialog_delete_entry.xml │ │ │ ├── dialog_duplicate_entry.xml │ │ │ ├── dialog_error.xml │ │ │ ├── dialog_export.xml │ │ │ ├── dialog_icon_picker.xml │ │ │ ├── dialog_importers.xml │ │ │ ├── dialog_number_picker.xml │ │ │ ├── dialog_password.xml │ │ │ ├── dialog_plaintext_warning.xml │ │ │ ├── dialog_progress.xml │ │ │ ├── dialog_select_group.xml │ │ │ ├── dialog_select_groups.xml │ │ │ ├── dialog_text_input.xml │ │ │ ├── dialog_time_sync.xml │ │ │ ├── dialog_web_view.xml │ │ │ ├── dropdown_checkbox.xml │ │ │ ├── dropdown_list_item.xml │ │ │ ├── fab_menu.xml │ │ │ ├── fragment_audit_log.xml │ │ │ ├── fragment_done_slide.xml │ │ │ ├── fragment_entry_list_view.xml │ │ │ ├── fragment_icon_packs.xml │ │ │ ├── fragment_security_picker_slide.xml │ │ │ ├── fragment_security_setup_slide.xml │ │ │ ├── fragment_welcome_slide.xml │ │ │ ├── popup_password.xml │ │ │ ├── scrim_layout.xml │ │ │ └── view_preference_switch.xml │ │ ├── menu/ │ │ │ ├── menu_action_mode.xml │ │ │ ├── menu_assign_icons.xml │ │ │ ├── menu_edit.xml │ │ │ ├── menu_groups.xml │ │ │ ├── menu_import_entries.xml │ │ │ ├── menu_main.xml │ │ │ └── menu_scanner.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ ├── ic_launcher_debug.xml │ │ │ └── ic_launcher_debug_round.xml │ │ ├── mipmap-anydpi-v33/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_debug.xml │ │ ├── raw/ │ │ │ ├── aboutlibraries.json │ │ │ └── keep.xml │ │ ├── values/ │ │ │ ├── arrays.xml │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── values-ar-rSA/ │ │ │ └── strings.xml │ │ ├── values-ar-rSA-v29/ │ │ │ └── strings.xml │ │ ├── values-ast-rES/ │ │ │ └── strings.xml │ │ ├── values-ast-rES-v29/ │ │ │ └── strings.xml │ │ ├── values-bg-rBG/ │ │ │ └── strings.xml │ │ ├── values-bg-rBG-v29/ │ │ │ └── strings.xml │ │ ├── values-ca-rES/ │ │ │ └── strings.xml │ │ ├── values-ca-rES-v29/ │ │ │ └── strings.xml │ │ ├── values-cs-rCZ/ │ │ │ └── strings.xml │ │ ├── values-cs-rCZ-v29/ │ │ │ └── strings.xml │ │ ├── values-da-rDK/ │ │ │ └── strings.xml │ │ ├── values-da-rDK-v29/ │ │ │ └── strings.xml │ │ ├── values-de-rDE/ │ │ │ └── strings.xml │ │ ├── values-de-rDE-v29/ │ │ │ └── strings.xml │ │ ├── values-el-rGR/ │ │ │ └── strings.xml │ │ ├── values-el-rGR-v29/ │ │ │ └── strings.xml │ │ ├── values-es-rES/ │ │ │ └── strings.xml │ │ ├── values-es-rES-v29/ │ │ │ └── strings.xml │ │ ├── values-et-rEE/ │ │ │ └── strings.xml │ │ ├── values-et-rEE-v29/ │ │ │ └── strings.xml │ │ ├── values-eu-rES/ │ │ │ └── strings.xml │ │ ├── values-eu-rES-v29/ │ │ │ └── strings.xml │ │ ├── values-fa-rIR/ │ │ │ └── strings.xml │ │ ├── values-fa-rIR-v29/ │ │ │ └── strings.xml │ │ ├── values-fi-rFI/ │ │ │ └── strings.xml │ │ ├── values-fi-rFI-v29/ │ │ │ └── strings.xml │ │ ├── values-fr-rFR/ │ │ │ └── strings.xml │ │ ├── values-fr-rFR-v29/ │ │ │ └── strings.xml │ │ ├── values-fy-rNL/ │ │ │ └── strings.xml │ │ ├── values-fy-rNL-v29/ │ │ │ └── strings.xml │ │ ├── values-gl-rES/ │ │ │ └── strings.xml │ │ ├── values-gl-rES-v29/ │ │ │ └── strings.xml │ │ ├── values-hi-rIN/ │ │ │ └── strings.xml │ │ ├── values-hi-rIN-v29/ │ │ │ └── strings.xml │ │ ├── values-hu-rHU/ │ │ │ └── strings.xml │ │ ├── values-hu-rHU-v29/ │ │ │ └── strings.xml │ │ ├── values-in-rID/ │ │ │ └── strings.xml │ │ ├── values-in-rID-v29/ │ │ │ └── strings.xml │ │ ├── values-it-rIT/ │ │ │ └── strings.xml │ │ ├── values-it-rIT-v29/ │ │ │ └── strings.xml │ │ ├── values-iw-rIL/ │ │ │ └── strings.xml │ │ ├── values-iw-rIL-v29/ │ │ │ └── strings.xml │ │ ├── values-ja-rJP/ │ │ │ └── strings.xml │ │ ├── values-ja-rJP-v29/ │ │ │ └── strings.xml │ │ ├── values-kn-rIN/ │ │ │ └── strings.xml │ │ ├── values-kn-rIN-v29/ │ │ │ └── strings.xml │ │ ├── values-ko-rKR/ │ │ │ └── strings.xml │ │ ├── values-ko-rKR-v29/ │ │ │ └── strings.xml │ │ ├── values-lt-rLT/ │ │ │ └── strings.xml │ │ ├── values-lt-rLT-v29/ │ │ │ └── strings.xml │ │ ├── values-lv-rLV/ │ │ │ └── strings.xml │ │ ├── values-lv-rLV-v29/ │ │ │ └── strings.xml │ │ ├── values-ml-rIN/ │ │ │ └── strings.xml │ │ ├── values-ml-rIN-v29/ │ │ │ └── strings.xml │ │ ├── values-nb-rNO/ │ │ │ └── strings.xml │ │ ├── values-nb-rNO-v29/ │ │ │ └── strings.xml │ │ ├── values-nl-rNL/ │ │ │ └── strings.xml │ │ ├── values-nl-rNL-v29/ │ │ │ └── strings.xml │ │ ├── values-pl-rPL/ │ │ │ └── strings.xml │ │ ├── values-pl-rPL-v29/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR-v29/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT-v29/ │ │ │ └── strings.xml │ │ ├── values-ro-rRO/ │ │ │ └── strings.xml │ │ ├── values-ro-rRO-v29/ │ │ │ └── strings.xml │ │ ├── values-ru-rRU/ │ │ │ └── strings.xml │ │ ├── values-ru-rRU-v29/ │ │ │ └── strings.xml │ │ ├── values-sk-rSK/ │ │ │ └── strings.xml │ │ ├── values-sk-rSK-v29/ │ │ │ └── strings.xml │ │ ├── values-sr-rSP/ │ │ │ └── strings.xml │ │ ├── values-sr-rSP-v29/ │ │ │ └── strings.xml │ │ ├── values-sv-rSE/ │ │ │ └── strings.xml │ │ ├── values-sv-rSE-v29/ │ │ │ └── strings.xml │ │ ├── values-tr-rTR/ │ │ │ └── strings.xml │ │ ├── values-tr-rTR-v29/ │ │ │ └── strings.xml │ │ ├── values-uk-rUA/ │ │ │ └── strings.xml │ │ ├── values-uk-rUA-v29/ │ │ │ └── strings.xml │ │ ├── values-v27/ │ │ │ └── themes.xml │ │ ├── values-v29/ │ │ │ └── strings.xml │ │ ├── values-vi-rVN/ │ │ │ └── strings.xml │ │ ├── values-vi-rVN-v29/ │ │ │ └── strings.xml │ │ ├── values-w820dp/ │ │ │ └── dimens.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── values-zh-rCN-v29/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW-v29/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── backup_rules.xml │ │ ├── backup_rules_old.xml │ │ ├── file_paths.xml │ │ ├── preferences.xml │ │ ├── preferences_appearance.xml │ │ ├── preferences_backups.xml │ │ ├── preferences_behavior.xml │ │ ├── preferences_import_export.xml │ │ └── preferences_security.xml │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── beemdevelopment/ │ │ └── aegis/ │ │ ├── PreferencesTest.java │ │ ├── crypto/ │ │ │ ├── SCryptTest.java │ │ │ └── otp/ │ │ │ ├── HOTPTest.java │ │ │ ├── MOTPTest.java │ │ │ ├── TOTPTest.java │ │ │ └── YAOTPTest.java │ │ ├── helpers/ │ │ │ └── QrCodeAnalyzerTest.java │ │ ├── importers/ │ │ │ └── DatabaseImporterTest.java │ │ ├── otp/ │ │ │ ├── GoogleAuthInfoTest.java │ │ │ ├── HotpInfoTest.java │ │ │ ├── MotpInfoTest.java │ │ │ ├── TotpInfoTest.java │ │ │ └── YandexInfoTest.java │ │ ├── util/ │ │ │ └── UUIDMapTest.java │ │ ├── vault/ │ │ │ ├── VaultTest.java │ │ │ └── slots/ │ │ │ └── SlotTest.java │ │ └── vectors/ │ │ └── VaultEntries.java │ └── resources/ │ └── com/ │ └── beemdevelopment/ │ └── aegis/ │ ├── importers/ │ │ ├── 2fas_authenticator.json │ │ ├── 2fas_authenticator_encrypted.2fas │ │ ├── 2fas_authenticator_encrypted_v3.2fas │ │ ├── 2fas_authenticator_encrypted_v4.2fas │ │ ├── 2fas_authenticator_plain.2fas │ │ ├── 2fas_authenticator_plain_v3.2fas │ │ ├── 2fas_authenticator_plain_v4.2fas │ │ ├── aegis_encrypted.json │ │ ├── aegis_plain.json │ │ ├── andotp_plain.json │ │ ├── authy_encrypted.xml │ │ ├── authy_plain.xml │ │ ├── battle_net_authenticator.xml │ │ ├── bitwarden.csv │ │ ├── bitwarden.json │ │ ├── duo.json │ │ ├── ente_auth.txt │ │ ├── freeotp.xml │ │ ├── freeotp_plus.json │ │ ├── freeotp_plus_internal.xml │ │ ├── freeotp_v2_api23.xml │ │ ├── freeotp_v2_api25.xml │ │ ├── freeotp_v2_api27.xml │ │ ├── freeotp_v2_api34.xml │ │ ├── freeotp_v2_null_algo.xml │ │ ├── plain.txt │ │ ├── proton_authenticator.json │ │ ├── steam.json │ │ ├── steam_old.json │ │ ├── stratum_plain.json │ │ └── totp_authenticator_internal.xml │ └── vault/ │ └── aegis_plain_grouped_v2.json ├── build.gradle ├── crowdin.yml ├── docs/ │ ├── decrypt.py │ ├── iconpacks.md │ └── vault.md ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── metadata/ │ └── en-US/ │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ buy_me_a_coffee: beemdevelopment custom: - "https://www.blockchain.com/btc/address/bc1q26kyxqjkc6tu477pzy0whagwhs4ypv93qls22n" - "https://nanocrawler.cc/explorer/account/nano_1aegisc559b1x4p3839egnu579jkd4htpidy14eo9e31gzqmwuafypnj4q94" ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ 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 ================================================ FILE: .github/ISSUE_TEMPLATE/feature.md ================================================ --- name: "Feature request" about: "Suggest a new feature for this project" labels: proposal --- ================================================ FILE: .github/workflows/build-app-workflow.yaml ================================================ 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 ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ 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}}" ================================================ FILE: .github/workflows/crowdin.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ # 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 ================================================ FILE: CONTRIBUTING.md ================================================ # 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. ================================================ FILE: FAQ.md ================================================ # 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. ================================================ FILE: LICENSE ================================================ 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 . ================================================ FILE: README.md ================================================ App icon # 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) ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle ================================================ 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 81 versionName "3.4.2" multiDexEnabled true buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" buildConfigField "String", "GIT_BRANCH", "\"${getGitBranch()}\"" buildConfigField "java.util.concurrent.atomic.AtomicBoolean", "TEST", "new java.util.concurrent.atomic.AtomicBoolean(false)" javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas"] } } testInstrumentationRunner "com.beemdevelopment.aegis.AegisTestRunner" testInstrumentationRunnerArguments clearPackageData: 'true' } 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' } ================================================ FILE: app/config/libraries/krop.json ================================================ { "uniqueId": "com.github.avito-tech:krop", "licenses": [ "MIT" ] } ================================================ FILE: app/config/libraries/libsu.json ================================================ { "uniqueId": "com.github.topjohnwu.libsu:.*::regex", "licenses": [ "Apache-2.0" ] } ================================================ FILE: app/config/libraries/textdrawable.json ================================================ { "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" ] } ================================================ FILE: app/config/libraries/trustedintents.json ================================================ { "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" ] } ================================================ FILE: app/config/licenses/3ca920d1875f7ad7ab04a2a331958577.json ================================================ { "hash": "3ca920d1875f7ad7ab04a2a331958577", "url": "https://github.com/guardianproject/TrustedIntents/blob/master/LICENSE.txt", "name": "LGPLv2.1" } ================================================ FILE: app/lint.xml ================================================ ================================================ FILE: app/proguard-rules.pro ================================================ -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.** ================================================ FILE: app/schemas/com.beemdevelopment.aegis.database.AppDatabase/1.json ================================================ { "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')" ] } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java ================================================ 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())); } }; } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestApplication.java ================================================ package com.beemdevelopment.aegis; import dagger.hilt.android.testing.CustomTestApplication; @CustomTestApplication(AegisApplicationBase.class) public interface AegisTestApplication { } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java ================================================ 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); } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/BackupExportTest.java ================================================ 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++; } } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/DeepLinkTest.java ================================================ 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); } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/EmptySecretTest.java ================================================ 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())); } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/IntroTest.java ================================================ 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; } } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/OverallTest.java ================================================ 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_menu_item_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()); } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/PanicTriggerTest.java ================================================ 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); } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/rules/ScreenshotTestRule.java ================================================ 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(); } } } ================================================ FILE: app/src/androidTest/java/com/beemdevelopment/aegis/vault/VaultRepositoryTest.java ================================================ 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); } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/changelog.html ================================================

Version 3.4.2

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

New

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

Fixes

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

Version 3.4.1

New

  • Support for importing from Proton Authenticator

Fixes

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

Version 3.4

New

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

Fixes

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

Miscellaneous

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

Version 3.3.4

Fixes

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

Version 3.3.3

Fixes

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

Version 3.3.2

New

  • Find entries by searching in multiple fields simultaneously

Fixes

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

Version 3.3.1

Fixes

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

Version 3.3

New

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

Fixes

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

Version 3.2

New

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

Fixes

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

Version 3.1.1

Fixes

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

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

Version 3.1

New

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

Fixes

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

Version 3.0.1

New

  • Support for importing from the new Battle.net app

Fixes

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

Version 3.0

New

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

Fixes

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

Version 2.2.2

New

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

Fixes

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

Version 2.2.1

New

  • Ability to automatically skip potential duplicates when importing entries

Fixes

  • Biometrics button on the unlock screen was unresponsive

Version 2.2

New

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

Fixes

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

Version 2.1.3

New

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

Fixes

  • A crash could occur because a Toast was incorrectly created

Version 2.1.2

Fixes

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

Version 2.1.1

New

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

Fixes

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

Version 2.1

New

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

Fixes

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

Version 2.0.3

New

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

Version 2.0.2

New

  • 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
================================================ FILE: app/src/main/assets/license.html ================================================
%1$s
================================================ FILE: app/src/main/java/com/amulyakhare/textdrawable/LICENSE ================================================ 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. ================================================ FILE: app/src/main/java/com/amulyakhare/textdrawable/TextDrawable.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/amulyakhare/textdrawable/util/ColorGenerator.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/AccountNamePosition.java ================================================ 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]; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java ================================================ package com.beemdevelopment.aegis; import dagger.hilt.android.HiltAndroidApp; @HiltAndroidApp public class AegisApplication extends AegisApplicationBase { } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/AegisApplicationBase.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/AegisBackupAgent.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/AegisModule.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/BackupsVersioningStrategy.java ================================================ package com.beemdevelopment.aegis; public enum BackupsVersioningStrategy { UNDEFINED, MULTIPLE_BACKUPS, SINGLE_BACKUP } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/CopyBehavior.java ================================================ 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]; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/EventType.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/GroupPlaceholderType.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/PassReminderFreq.java ================================================ 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]; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/Preferences.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/SortCategory.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/Theme.java ================================================ 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]; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ThemeMap.java ================================================ 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 ); } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/VibrationPatterns.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ViewMode.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/CryptParameters.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/CryptResult.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/CryptoUtils.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandle.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/KeyStoreHandleException.java ================================================ package com.beemdevelopment.aegis.crypto; public class KeyStoreHandleException extends Exception { public KeyStoreHandleException(Throwable cause) { super(cause); } public KeyStoreHandleException(String message) { super(message); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKey.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/MasterKeyException.java ================================================ package com.beemdevelopment.aegis.crypto; public class MasterKeyException extends Exception { public MasterKeyException(Throwable cause) { super(cause); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/SCryptParameters.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/bc/SCrypt.java ================================================ /* 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/bc/Salsa20Engine.java ================================================ /* 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]; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/otp/HOTP.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/otp/MOTP.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/otp/OTP.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/otp/TOTP.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/otp/YAOTP.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/crypto/pins/GuardianProjectFDroidRSA2048.java ================================================ 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},}; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/database/AppDatabase.java ================================================ 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(); } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/database/AuditLogDao.java ================================================ 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(); } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/database/AuditLogEntry.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/database/AuditLogRepository.java ================================================ 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); }); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/encoding/Base32.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/encoding/Base64.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/encoding/EncodingException.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/encoding/Hex.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/AnimationsHelper.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricSlotInitializer.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/BiometricsHelper.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/CenterVerticalSpan.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/ContextHelper.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/DropdownHelper.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/EditTextHelper.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/FabMenuHelper.java ================================================ package com.beemdevelopment.aegis.helpers; import android.animation.ValueAnimator; import android.graphics.Matrix; import android.graphics.drawable.Drawable; import android.view.View; import android.view.ViewGroup; import android.view.animation.OvershootInterpolator; import android.widget.ImageView; import com.google.android.material.floatingactionbutton.FloatingActionButton; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Consumer; public class FabMenuHelper { private final static long ANIMATION_DURATION = 300L; private final static long ANIMATION_ACTION_DELAY = 50L; private final View _scrim; private final View _menuItemsContainer; private final FloatingActionButton _mainFab; private final List _actions; private Consumer _stateListener; private boolean _isOpen = false; public FabMenuHelper( View scrim, ViewGroup menuItemsContainer, FloatingActionButton fab, Map actions ) { _scrim = scrim; _menuItemsContainer = menuItemsContainer; _mainFab = fab; _actions = new ArrayList<>(actions.keySet()); for (View action : _actions) { action.setVisibility(View.GONE); action.setAlpha(0f); action.setScaleX(0f); action.setScaleY(0f); } setupClickListeners(actions); } public void setOnFabMenuStateChangeListener(Consumer listener) { _stateListener = listener; } private void setupClickListeners(Map actions) { _mainFab.setOnClickListener(v -> toggle()); _scrim.setOnClickListener(v -> close()); actions.forEach((action, onClick) -> { action.setOnClickListener(v -> { if (onClick != null) { onClick.run(); } close(); }); }); } public void toggle() { if (_isOpen) { close(); } else { open(); } } public void open() { if (_isOpen) { return; } _isOpen = true; _scrim.animate() .alpha(0.5f) .setDuration(ANIMATION_DURATION) .withStartAction(() -> _scrim.setVisibility(View.VISIBLE)) .start(); _menuItemsContainer.setVisibility(View.VISIBLE); long delay = 0L; for (int i = _actions.size() - 1; i >= 0; i--) { animateActionIn(_actions.get(i), delay); delay += ANIMATION_ACTION_DELAY; } animateFabIconForward(_mainFab); if (_stateListener != null) { _stateListener.accept(true); } } public void close() { if (!_isOpen) { return; } _isOpen = false; _scrim.animate() .alpha(0f) .setDuration(ANIMATION_DURATION) .withEndAction(() -> _scrim.setVisibility(View.GONE)) .start(); long delay = 0L; for (View action : _actions) { animateActionOut(action, delay); delay += ANIMATION_ACTION_DELAY; } animateFabIconBackward(_mainFab); _mainFab.postDelayed(() -> { if (!_isOpen) { _menuItemsContainer.setVisibility(View.GONE); } }, ANIMATION_DURATION); if (_stateListener != null) { _stateListener.accept(false); } } private void animateFabIconForward(FloatingActionButton fab) { animateFabIcon(fab, 0f, 45f); } private void animateFabIconBackward(FloatingActionButton fab) { animateFabIcon(fab, 45f, 0f); } private void animateFabIcon(FloatingActionButton fab, float from, float to) { Drawable drawable = _mainFab.getDrawable(); int width = drawable.getIntrinsicWidth(); int height = drawable.getIntrinsicHeight(); fab.setScaleType(ImageView.ScaleType.MATRIX); Matrix matrix = new Matrix(); ValueAnimator anim = ValueAnimator.ofFloat(from, to); anim.setDuration(100L); anim.addUpdateListener(valueAnimator -> { Float angle = (Float) valueAnimator.getAnimatedValue(); matrix.reset(); matrix.postRotate(angle, width / 2f, height / 2f); fab.setImageMatrix(matrix); }); anim.start(); } private void animateActionIn(View action, long delay) { action.setVisibility(View.VISIBLE); action.setAlpha(0f); action.setScaleX(0.4f); action.setScaleY(0.4f); action.animate() .alpha(1f) .scaleX(1f) .scaleY(1f) .setDuration(ANIMATION_DURATION) .setStartDelay(delay) .setInterpolator(new OvershootInterpolator(1.2f)) .start(); } private void animateActionOut(View action, long delay) { action.animate() .alpha(0f) .scaleX(0f) .scaleY(0f) .setDuration(ANIMATION_DURATION) .setStartDelay(delay) .withEndAction(() -> action.setVisibility(View.GONE)) .start(); } public boolean isOpen() { return _isOpen; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/FabScrollHelper.java ================================================ 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(); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/ItemTouchHelperAdapter.java ================================================ 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); } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/MetricsHelper.java ================================================ 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)); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/PasswordStrengthHelper.java ================================================ 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]; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/PermissionHelper.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/SafHelper.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleAnimationEndListener.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleTextWatcher.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/ThemeHelper.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/UiRefresher.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/UiThreadExecutor.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/VibrationHelper.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/ViewHelper.java ================================================ 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; }); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/AccountNameComparator.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/FavoriteComparator.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/IssuerNameComparator.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/LastUsedComparator.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/helpers/comparators/UsageCountComparator.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java ================================================ 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 } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java ================================================ package com.beemdevelopment.aegis.icons; public class IconPackException extends Exception { public IconPackException(Throwable cause) { super(cause); } public IconPackException(String message) { super(message); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java ================================================ 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)); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java ================================================ 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)); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/AndOtpImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/AuthenticatorPlusImporter.java ================================================ 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()); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/AuthyImporter.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/BattleNetImporter.java ================================================ 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(); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/BitwardenImporter.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterEntryException.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporterException.java ================================================ package com.beemdevelopment.aegis.importers; public class DatabaseImporterException extends Exception { public DatabaseImporterException(Throwable cause) { super(cause); } public DatabaseImporterException(String message) { super(message); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/DuoImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/EnteAuthImporter.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpImporter.java ================================================ 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); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/FreeOtpPlusImporter.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthImporter.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/GoogleAuthUriImporter.java ================================================ 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); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/MicrosoftAuthImporter.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/ProtonAuthenticatorImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/SqlImporterHelper.java ================================================ 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) { } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/SteamImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/StratumImporter.java ================================================ 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); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/TotpAuthenticatorImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/TwoFASImporter.java ================================================ 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()); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/importers/WinAuthImporter.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfo.java ================================================ 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(); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/GoogleAuthInfoException.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/HotpInfo.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/MotpInfo.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfo.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/OtpInfoException.java ================================================ package com.beemdevelopment.aegis.otp; public class OtpInfoException extends Exception { public OtpInfoException(Throwable cause) { super(cause); } public OtpInfoException(String message) { super(message); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/SteamInfo.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/TotpInfo.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/Transferable.java ================================================ package com.beemdevelopment.aegis.otp; import android.net.Uri; public interface Transferable { Uri getUri() throws GoogleAuthInfoException; } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/otp/YandexInfo.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/receivers/QsTileRefreshReceiver.java ================================================ package com.beemdevelopment.aegis.receivers; import static android.content.Intent.ACTION_BOOT_COMPLETED; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.os.Build; import android.service.quicksettings.TileService; import com.beemdevelopment.aegis.services.LaunchAppTileService; import com.beemdevelopment.aegis.services.LaunchScannerTileService; public class QsTileRefreshReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (intent.getAction() == null || (!intent.getAction().equals(ACTION_BOOT_COMPLETED) && !intent.getAction().equals(Intent.ACTION_USER_UNLOCKED))) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { TileService.requestListeningState(context, new ComponentName(context, LaunchAppTileService.class)); TileService.requestListeningState(context, new ComponentName(context, LaunchScannerTileService.class)); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/receivers/VaultLockReceiver.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/services/LaunchAppTileService.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/services/LaunchScannerTileService.java ================================================ 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); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/services/NotificationService.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/AboutActivity.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/AegisActivity.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/AssignIconsActivity.java ================================================ 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; } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/AuthActivity.java ================================================ 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(); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java ================================================ 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; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/ExitActivity.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java ================================================ 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(); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java ================================================ 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; } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/IntroActivity.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/LicensesActivity.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java ================================================ 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.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.AutoCompleteTextView; import android.widget.Button; import android.widget.CheckBox; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.SearchView; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import com.beemdevelopment.aegis.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.FabMenuHelper; 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.ClipboardUtils; import com.beemdevelopment.aegis.util.TimeUtils; import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.vault.VaultEntryIcon; import com.beemdevelopment.aegis.vault.VaultFile; import com.beemdevelopment.aegis.vault.VaultGroup; import com.beemdevelopment.aegis.vault.VaultRepository; import com.beemdevelopment.aegis.vault.VaultRepositoryException; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; import com.google.android.material.color.MaterialColors; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import com.google.common.base.Strings; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; public class MainActivity extends AegisActivity implements EntryListView.Listener { // 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 FabMenuHelper _fabMenuHelper; private ActionMode _actionMode; private ActionMode.Callback _actionModeCallbacks = new ActionModeCallbacks(); private LockBackPressHandler _lockBackPressHandler; private SearchViewBackPressHandler _searchViewBackPressHandler; private ActionModeBackPressHandler _actionModeBackPressHandler; private FabMenuBackPressHandler _fabMenuBackPressHandler; private final ActivityResultLauncher authResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { _isAuthenticating = false; if (activityResult.getResultCode() == RESULT_OK) { onDecryptResult(); } }); private final ActivityResultLauncher introResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { _isDoingIntro = false; if (activityResult.getResultCode() == RESULT_OK) { onIntroResult(); } }); private final ActivityResultLauncher scanResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { return; } onScanResult(activityResult.getData()); }); private final ActivityResultLauncher assignIconsResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { return; } onAssignIconsResult(); }); private final ActivityResultLauncher preferenceResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> onPreferencesResult()); private final ActivityResultLauncher editEntryResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { return; } onEditEntryResult(); }); private final ActivityResultLauncher addEntryResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { return; } onAddEntryResult(activityResult.getData()); }); private final ActivityResultLauncher codeScanResultLauncher = registerForActivityResult(new StartActivityForResult(), activityResult -> { if (activityResult.getResultCode() == RESULT_OK && activityResult.getData() != null) { onScanImageResult(activityResult.getData()); } }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); setSupportActionBar(findViewById(R.id.toolbar)); 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); _fabMenuBackPressHandler = new FabMenuBackPressHandler(); getOnBackPressedDispatcher().addCallback(this, _fabMenuBackPressHandler); _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(); View scrimOverlayLayout = LayoutInflater.from(this).inflate(R.layout.scrim_layout, null); View scrimOverlay = scrimOverlayLayout.findViewById(R.id.scrim); addContentView(scrimOverlayLayout, new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT )); View fabMenuLayout = LayoutInflater.from(this).inflate(R.layout.fab_menu, null); addContentView(fabMenuLayout, new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT )); ViewGroup menuItemsContainer = fabMenuLayout.findViewById(R.id.fab_menu_items_container); FloatingActionButton fab = fabMenuLayout.findViewById(R.id.fab); LinkedHashMap actions = new LinkedHashMap<>(); actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_scan), this::startScanActivity); actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_scan_image), this::startScanImageActivity); actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_enter), this::startEditEntryActivity); _fabMenuHelper = new FabMenuHelper(scrimOverlay, menuItemsContainer, fab, actions); _fabMenuHelper.setOnFabMenuStateChangeListener(_fabMenuBackPressHandler::setEnabled); _groupChip = findViewById(R.id.groupChipGroup); _fabScrollHelper = new FabScrollHelper(fab); _selectedEntries = new ArrayList<>(); } 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 startEditEntryActivity() { String clip = ClipboardUtils.readText(this); if (clip != null) { GoogleAuthInfo parsed; try { parsed = GoogleAuthInfo.parseUri(clip.trim()); String message = getString( R.string.import_from_clipboard_message, parsed.getAccountName(), parsed.getIssuer() ); Dialogs.showSecureDialog(new MaterialAlertDialogBuilder(this) .setTitle(R.string.import_from_clipboard_title) .setMessage(message) .setPositiveButton(R.string.yes, (dialog, which) -> startEditEntryActivityForNew(new VaultEntry(parsed))) .setNegativeButton(R.string.no, (dialog, which) -> startEditEntryActivityForManual()) .create()); return; } catch (GoogleAuthInfoException e) { Log.i("EntryActivity", "Clipboard did not contain a valid otpauth URI", e); } } startEditEntryActivityForManual(); } private void startEditEntryActivityForNew(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(); } if (_fabMenuHelper != null && _fabMenuHelper.isOpen()) { _fabMenuHelper.close(); } _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 FabMenuBackPressHandler extends OnBackPressedCallback { public FabMenuBackPressHandler() { super(false); } @Override public void handleOnBackPressed() { if (_fabMenuHelper != null && _fabMenuHelper.isOpen()) { _fabMenuHelper.close(); } } } private class ActionModeCallbacks implements ActionMode.Callback { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.menu_action_mode, menu); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (_selectedEntries.size() == 0) { mode.finish(); return true; } int itemId = item.getItemId(); if (itemId == R.id.action_copy) { copyEntryCode(_selectedEntries.get(0)); mode.finish(); } else if (itemId == R.id.action_edit) { startEditEntryActivity(_selectedEntries.get(0)); mode.finish(); } else if (itemId == R.id.action_toggle_favorite) { for (VaultEntry entry : _selectedEntries) { _vaultManager.getVault().editEntry(entry, newEntry -> { newEntry.setIsFavorite(!newEntry.isFavorite()); }); } saveAndBackupVault(); _entryListView.setEntries(_vaultManager.getVault().getEntries()); mode.finish(); } else if (itemId == R.id.action_share_qr) { Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class); ArrayList authInfos = new ArrayList<>(); for (VaultEntry entry : _selectedEntries) { GoogleAuthInfo authInfo = new GoogleAuthInfo(entry.getInfo(), entry.getName(), entry.getIssuer()); authInfos.add(authInfo); _auditLogRepository.addEntrySharedEvent(entry.getUUID().toString()); } intent.putExtra("authInfos", authInfos); startActivity(intent); mode.finish(); } else if (itemId == R.id.action_delete) { Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { for (VaultEntry entry : _selectedEntries) { _vaultManager.getVault().removeEntry(entry); } saveAndBackupVault(); _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); _entryListView.setEntries(_vaultManager.getVault().getEntries()); mode.finish(); }); } else if (itemId == R.id.action_select_all) { _selectedEntries = _entryListView.selectAllEntries(); setFavoriteMenuItemVisiblity(); setIsMultipleSelected(_selectedEntries.size() > 1); } else if (itemId == R.id.action_assign_icons) { startAssignIconsActivity(_selectedEntries); mode.finish(); } else if (itemId == R.id.action_assign_groups) { startAssignGroupsDialog(); } else { return false; } return true; } @Override public void onDestroyActionMode(ActionMode mode) { _entryListView.setActionModeState(false, null); _actionModeBackPressHandler.setEnabled(false); _selectedEntries.clear(); _actionMode = null; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/PanicResponderActivity.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java ================================================ 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); } } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/ScannerActivity.java ================================================ 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(); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java ================================================ 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); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java ================================================ 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(); } }; } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/components/NoAutofillEditText.java ================================================ 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(); } } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/ChangelogDialog.java ================================================ 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()); } } ================================================ FILE: app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java ================================================ 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