From e59df63e94e68fdc826028599e26af5f408b83bd Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sun, 5 Jan 2025 16:11:37 +0100 Subject: [PATCH] Store non-SVG icons at a maximum of 512x512 and migrate existing icons --- .../aegis/helpers/BitmapHelper.java | 32 ++++++++++ .../aegis/ui/EditEntryActivity.java | 20 +++--- .../aegis/ui/ImportEntriesActivity.java | 40 +++++++++--- .../aegis/ui/MainActivity.java | 38 ++++++++++- .../aegis/ui/tasks/IconOptimizationTask.java | 63 +++++++++++++++++++ .../beemdevelopment/aegis/vault/Vault.java | 14 +++++ .../aegis/vault/VaultEntry.java | 10 ++- .../aegis/vault/VaultEntryIcon.java | 8 ++- .../aegis/vault/VaultEntryIconException.java | 11 ++++ .../aegis/vault/VaultRepository.java | 8 +++ app/src/main/res/values/strings.xml | 2 + 11 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/beemdevelopment/aegis/ui/tasks/IconOptimizationTask.java create mode 100644 app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIconException.java diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java index a97fb6a46c..339c1760f9 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/BitmapHelper.java @@ -1,6 +1,13 @@ 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() { @@ -28,4 +35,29 @@ public static Bitmap resize(Bitmap bitmap, int maxWidth, int maxHeight) { return Bitmap.createScaledBitmap(bitmap, width, height, true); } + + public static boolean isVaultEntryIconOptimized(VaultEntryIcon icon) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(icon.getBytes(), 0, icon.getBytes().length, opts); + return opts.outWidth <= VaultEntryIcon.MAX_DIMENS && opts.outHeight <= VaultEntryIcon.MAX_DIMENS; + } + + public static VaultEntryIcon toVaultEntryIcon(Bitmap bitmap, IconType iconType) { + if (bitmap.getWidth() > VaultEntryIcon.MAX_DIMENS + || bitmap.getHeight() > VaultEntryIcon.MAX_DIMENS) { + bitmap = resize(bitmap, VaultEntryIcon.MAX_DIMENS, VaultEntryIcon.MAX_DIMENS); + } + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + if (Objects.equals(iconType, IconType.PNG)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + } else { + iconType = IconType.JPEG; + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); + } + + byte[] data = stream.toByteArray(); + return new VaultEntryIcon(data, iconType); + } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java index 086559dea3..a7c9259113 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java @@ -35,12 +35,14 @@ 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; @@ -59,7 +61,6 @@ import com.beemdevelopment.aegis.ui.views.IconAdapter; import com.beemdevelopment.aegis.util.Cloner; 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.beemdevelopment.aegis.vault.VaultGroup; @@ -76,7 +77,6 @@ import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -103,6 +103,7 @@ public class EditEntryActivity extends AegisActivity { // keep track of icon changes separately as the generated jpeg's are not deterministic private boolean _hasChangedIcon = false; private IconPack.Icon _selectedIcon; + private String _pickedMimeType; private ShapeableImageView _iconView; private ImageView _saveImageButton; @@ -140,8 +141,8 @@ public class EditEntryActivity extends AegisActivity { if (activityResult.getResultCode() != RESULT_OK || data == null || data.getData() == null) { return; } - String fileType = SafHelper.getMimeType(this, data.getData()); - if (fileType != null && fileType.equals(IconType.SVG.toMimeType())) { + _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) { @@ -804,11 +805,12 @@ private VaultEntry parseEntry() throws ParseException { VaultEntryIcon icon; if (_selectedIcon == null) { Bitmap bitmap = ((BitmapDrawable) _iconView.getDrawable()).getBitmap(); - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - // the quality parameter is ignored for PNG - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); - byte[] data = stream.toByteArray(); - icon = new VaultEntryIcon(data, IconType.PNG); + 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())){ diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java index 0bf70b368d..9a5cec4083 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java @@ -15,17 +15,21 @@ 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.helpers.ViewHelper; 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; @@ -40,8 +44,10 @@ 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; @@ -172,7 +178,7 @@ private void processImporterState(DatabaseImporter.State state) { state.decrypt(this, new DatabaseImporter.DecryptListener() { @Override public void onStateDecrypted(DatabaseImporter.State state) { - importDatabase(state); + processDecryptedImporterState(state); } @Override @@ -187,7 +193,7 @@ public void onCanceled() { } }); } else { - importDatabase(state); + processDecryptedImporterState(state); } } catch (DatabaseImporterException e) { e.printStackTrace(); @@ -195,8 +201,7 @@ public void onCanceled() { } } - private void importDatabase(DatabaseImporter.State state) { - List importEntries = new ArrayList<>(); + private void processDecryptedImporterState(DatabaseImporter.State state) { DatabaseImporter.Result result; try { result = state.convert(); @@ -206,8 +211,29 @@ private void importDatabase(DatabaseImporter.State state) { return; } - UUIDMap entries = result.getEntries(); - for (VaultEntry entry : entries.getValues()) { + 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); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java index 1dc5cbd531..0a4767c89a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -44,9 +44,12 @@ import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.SortCategory; +import com.beemdevelopment.aegis.helpers.BitmapHelper; import com.beemdevelopment.aegis.helpers.DropdownHelper; import com.beemdevelopment.aegis.helpers.FabScrollHelper; import com.beemdevelopment.aegis.helpers.PermissionHelper; +import com.beemdevelopment.aegis.helpers.ViewHelper; +import com.beemdevelopment.aegis.icons.IconType; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; import com.beemdevelopment.aegis.otp.OtpInfoException; @@ -55,12 +58,13 @@ import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment; import com.beemdevelopment.aegis.ui.models.ErrorCardInfo; import com.beemdevelopment.aegis.ui.models.VaultGroupModel; +import com.beemdevelopment.aegis.ui.tasks.IconOptimizationTask; import com.beemdevelopment.aegis.ui.tasks.QrDecodeTask; import com.beemdevelopment.aegis.ui.views.EntryListView; import com.beemdevelopment.aegis.util.TimeUtils; import com.beemdevelopment.aegis.util.UUIDMap; -import com.beemdevelopment.aegis.helpers.ViewHelper; 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; @@ -724,6 +728,37 @@ private void checkTimeSyncSetting() { } } + 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(); @@ -912,6 +947,7 @@ protected void onStart() { } else { loadEntries(); checkTimeSyncSetting(); + checkIconOptimization(); } _lockBackPressHandler.setEnabled( diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/IconOptimizationTask.java b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/IconOptimizationTask.java new file mode 100644 index 0000000000..d7be90d568 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/IconOptimizationTask.java @@ -0,0 +1,63 @@ +package com.beemdevelopment.aegis.ui.tasks; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.BitmapHelper; +import com.beemdevelopment.aegis.icons.IconType; +import com.beemdevelopment.aegis.vault.VaultEntryIcon; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class IconOptimizationTask extends ProgressDialogTask, Map> { + private final Callback _cb; + + public IconOptimizationTask(Context context, Callback cb) { + super(context, context.getString(R.string.optimizing_icon)); + _cb = cb; + } + + @Override + protected Map doInBackground(Map... params) { + Map res = new HashMap<>(); + Context context = getDialog().getContext(); + + int i = 0; + Map icons = params[0]; + for (Map.Entry entry : icons.entrySet()) { + if (icons.size() > 1) { + publishProgress(context.getString(R.string.optimizing_icon_multiple, i + 1, icons.size())); + } + i++; + + VaultEntryIcon oldIcon = entry.getValue(); + if (oldIcon == null || oldIcon.getType().equals(IconType.SVG)) { + continue; + } + if (BitmapHelper.isVaultEntryIconOptimized(oldIcon)) { + continue; + } + + Bitmap bitmap = BitmapFactory.decodeByteArray(oldIcon.getBytes(), 0, oldIcon.getBytes().length); + VaultEntryIcon newIcon = BitmapHelper.toVaultEntryIcon(bitmap, oldIcon.getType()); + bitmap.recycle(); + res.put(entry.getKey(), newIcon); + } + + return res; + } + + @Override + protected void onPostExecute(Map results) { + super.onPostExecute(results); + _cb.onTaskFinished(results); + } + + public interface Callback { + void onTaskFinished(Map results); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java b/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java index ff90046d2c..74f8a126e3 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java @@ -15,6 +15,7 @@ public class Vault { private static final int VERSION = 3; private final UUIDMap _entries = new UUIDMap<>(); private final UUIDMap _groups = new UUIDMap<>(); + private boolean _iconsOptimized = true; // Whether we've migrated the group list to the new format while parsing the vault private boolean _isGroupsMigrationFresh = false; @@ -42,6 +43,7 @@ public JSONObject toJson(@Nullable EntryFilter filter) { obj.put("version", VERSION); obj.put("entries", entriesArray); obj.put("groups", groupsArray); + obj.put("icons_optimized", _iconsOptimized); return obj; } catch (JSONException e) { @@ -86,6 +88,10 @@ public static Vault fromJson(JSONObject obj) throws VaultException { entries.add(entry); } + + if (!obj.optBoolean("icons_optimized")) { + vault.setIconsOptimized(false); + } } catch (VaultEntryException | JSONException e) { throw new VaultException(e); } @@ -101,6 +107,14 @@ public boolean isGroupsMigrationFresh() { return _isGroupsMigrationFresh; } + public void setIconsOptimized(boolean optimized) { + _iconsOptimized = optimized; + } + + public boolean areIconsOptimized() { + return _iconsOptimized; + } + public boolean migrateOldGroup(VaultEntry entry) { if (entry.getOldGroup() != null) { Optional optGroup = getGroups().getValues() diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java index 080580c952..1fafb5ab61 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java @@ -103,8 +103,14 @@ public static VaultEntry fromJson(JSONObject obj) throws VaultEntryException { entry.setOldGroup(JsonUtils.optString(obj, "group")); } - VaultEntryIcon icon = VaultEntryIcon.fromJson(obj); - entry.setIcon(icon); + // Silently ignore any errors that occur when trying to parse the icon of an + // entry. This allows us to introduce new icon types in the future (e.g. WebP) + // without breaking compatibility with older versions of Aegis. + try { + VaultEntryIcon icon = VaultEntryIcon.fromJson(obj); + entry.setIcon(icon); + } catch (VaultEntryIconException ignored) { + } return entry; } catch (OtpInfoException | JSONException e) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIcon.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIcon.java index 44c7babb95..f45a71ec8e 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIcon.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIcon.java @@ -23,6 +23,8 @@ public class VaultEntryIcon implements Serializable { private final byte[] _hash; private final IconType _type; + public static final int MAX_DIMENS = 512; + public VaultEntryIcon(byte @NonNull [] bytes, @NonNull IconType type) { this(bytes, type, generateHash(bytes, type)); } @@ -70,7 +72,7 @@ static void toJson(@Nullable VaultEntryIcon icon, @NonNull JSONObject obj) throw } @Nullable - static VaultEntryIcon fromJson(@NonNull JSONObject obj) throws VaultEntryException { + static VaultEntryIcon fromJson(@NonNull JSONObject obj) throws VaultEntryIconException { try { Object icon = obj.get("icon"); if (icon == JSONObject.NULL) { @@ -80,7 +82,7 @@ static VaultEntryIcon fromJson(@NonNull JSONObject obj) throws VaultEntryExcepti String mime = JsonUtils.optString(obj, "icon_mime"); IconType iconType = mime == null ? IconType.JPEG : IconType.fromMimeType(mime); if (iconType == IconType.INVALID) { - throw new VaultEntryException(String.format("Bad icon MIME type: %s", mime)); + throw new VaultEntryIconException(String.format("Bad icon MIME type: %s", mime)); } byte[] iconBytes = Base64.decode((String) icon); @@ -92,7 +94,7 @@ static VaultEntryIcon fromJson(@NonNull JSONObject obj) throws VaultEntryExcepti return new VaultEntryIcon(iconBytes, iconType); } catch (JSONException | EncodingException e) { - throw new VaultEntryException(e); + throw new VaultEntryIconException(e); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIconException.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIconException.java new file mode 100644 index 0000000000..2391de3ecc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryIconException.java @@ -0,0 +1,11 @@ +package com.beemdevelopment.aegis.vault; + +public class VaultEntryIconException extends Exception { + public VaultEntryIconException(Throwable cause) { + super(cause); + } + + public VaultEntryIconException(String message) { + super(message); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java index 7792c953bc..865d126eed 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java @@ -333,6 +333,14 @@ public boolean isGroupsMigrationFresh() { return _vault.isGroupsMigrationFresh(); } + public boolean areIconsOptimized() { + return _vault.areIconsOptimized(); + } + + public void setIconsOptimized(boolean optimized) { + _vault.setIconsOptimized(optimized); + } + public VaultFileCredentials getCredentials() { return _creds == null ? null : _creds.clone(); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2725c77f2a..9683b2f91c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,6 +236,8 @@ Please select an authentication method Encrypting the vault Exporting the vault + Optimizing icon + Optimizing icons %1$d/%2$d Reading file Requesting root access Analyzing QR code