diff --git a/app/build.gradle b/app/build.gradle index 908d381c..950e94f1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -145,6 +145,7 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.1.0' implementation "androidx.viewpager2:viewpager2:1.0.0" implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' + implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.github.avito-tech:krop:0.52' implementation "com.github.bumptech.glide:annotations:${glideVersion}" implementation "com.github.bumptech.glide:glide:${glideVersion}" diff --git a/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java b/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java index 302d74e6..6867ebdb 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java +++ b/app/src/main/java/com/beemdevelopment/aegis/AegisApplication.java @@ -19,6 +19,7 @@ import androidx.lifecycle.LifecycleEventObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner; +import com.beemdevelopment.aegis.icons.IconPackManager; import com.beemdevelopment.aegis.services.NotificationService; import com.beemdevelopment.aegis.ui.MainActivity; import com.beemdevelopment.aegis.util.IOUtils; @@ -41,6 +42,7 @@ public class AegisApplication extends Application { private Preferences _prefs; private List _lockListeners; private boolean _blockAutoLock; + private IconPackManager _iconPackManager; private static final String CODE_LOCK_STATUS_ID = "lock_status_channel"; private static final String CODE_LOCK_VAULT_ACTION = "lock_vault"; @@ -55,6 +57,7 @@ public class AegisApplication extends Application { super.onCreate(); _prefs = new Preferences(this); _lockListeners = new ArrayList<>(); + _iconPackManager = new IconPackManager(this); Iconics.init(this); Iconics.registerFont(new MaterialDesignIconic()); @@ -126,6 +129,10 @@ public class AegisApplication extends Application { return _manager; } + public IconPackManager getIconPackManager() { + return _iconPackManager; + } + public Preferences getPreferences() { return _prefs; } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/IconViewHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/IconViewHelper.java new file mode 100644 index 00000000..06fdd667 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/IconViewHelper.java @@ -0,0 +1,25 @@ +package com.beemdevelopment.aegis.helpers; + +import android.os.Build; +import android.widget.ImageView; + +import com.beemdevelopment.aegis.icons.IconType; + +public class IconViewHelper { + private IconViewHelper() { + + } + + /** + * Sets the layer type of the given ImageView based on the given IconType. If the + * icon type is SVG and SDK <= 27, the layer type is set to software. Otherwise, it + * is set to hardware. + */ + public static void setLayerType(ImageView view, IconType iconType) { + if (iconType == IconType.SVG && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { + view.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null); + } + + view.setLayerType(ImageView.LAYER_TYPE_HARDWARE, null); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java index 5e405e3e..6d2cca95 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/TextDrawableHelper.java @@ -48,6 +48,6 @@ public class TextDrawableHelper { .width(view.getLayoutParams().width) .height(view.getLayoutParams().height) .endConfig() - .buildRect(text.substring(0, 1).toUpperCase(), color); + .buildRound(text.substring(0, 1).toUpperCase(), color); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java new file mode 100644 index 00000000..a25b1506 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPack.java @@ -0,0 +1,183 @@ +package com.beemdevelopment.aegis.icons; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +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; +import java.util.stream.Collectors; + +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<>(); + } + + return _icons.stream() + .filter(i -> i.isSuggestedFor(issuer)) + .collect(Collectors.toList()); + } + + @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 _category; + private final List _issuers; + + private File _file; + + protected Icon(String filename, String category, List issuers) { + _relFilename = filename; + _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); + } + + @SuppressWarnings("UnstableApiUsage") + public String getName() { + return Files.getNameWithoutExtension(new File(_relFilename).getName()); + } + + public String getCategory() { + return _category; + } + + public List getIssuers() { + return Collections.unmodifiableList(_issuers); + } + + public boolean isSuggestedFor(String issuer) { + return getIssuers().stream() + .anyMatch(is -> is.toLowerCase().contains(issuer.toLowerCase())); + } + + public static Icon fromJson(JSONObject obj) throws JSONException { + String filename = obj.getString("filename"); + 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, category, issuers); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java new file mode 100644 index 00000000..989faf4e --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackException.java @@ -0,0 +1,11 @@ +package com.beemdevelopment.aegis.icons; + +public class IconPackException extends Exception { + public IconPackException(Throwable cause) { + super(cause); + } + + public IconPackException(String message) { + super(message); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java new file mode 100644 index 00000000..5009f73b --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackExistsException.java @@ -0,0 +1,14 @@ +package com.beemdevelopment.aegis.icons; + +public class IconPackExistsException extends IconPackException { + private IconPack _pack; + + public IconPackExistsException(IconPack pack) { + super(String.format("Icon pack %s (%d) already exists", pack.getName(), pack.getVersion())); + _pack = pack; + } + + public IconPack getIconPack() { + return _pack; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java new file mode 100644 index 00000000..05b2cf29 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconPackManager.java @@ -0,0 +1,219 @@ +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 List getIconPacks() { + return new ArrayList<>(_iconPacks); + } + + public void removeIconPack(IconPack pack) throws IconPackException { + try { + File dir = getIconPackDir(pack); + deleteDir(dir); + } catch (IOException e) { + throw new IconPackException(e); + } + + _iconPacks.remove(pack); + } + + public IconPack importPack(File inFile) throws IconPackException { + try { + // read and parse the icon pack definition file of the icon pack + ZipFile zipFile = new ZipFile(inFile); + FileHeader packHeader = zipFile.getFileHeader(_packDefFilename); + if (packHeader == null) { + throw new IOException("Unable to find pack.json in the root of the ZIP file"); + } + IconPack pack; + byte[] defBytes; + try (ZipInputStream inStream = zipFile.getInputStream(packHeader)) { + defBytes = IOUtils.readAll(inStream); + pack = IconPack.fromBytes(defBytes); + } + + // create a new directory to store the icon pack, based on the UUID and version + File packDir = getIconPackDir(pack); + if (!packDir.getCanonicalPath().startsWith(_iconsBaseDir.getCanonicalPath() + File.separator)) { + throw new IOException("Attempted to write outside of the parent directory"); + } + if (packDir.exists()) { + throw new IconPackExistsException(pack); + } + IconPack existingPack = getIconPackByUUID(pack.getUUID()); + if (existingPack != null) { + throw new IconPackExistsException(existingPack); + } + if (!packDir.exists() && !packDir.mkdirs()) { + throw new IOException(String.format("Unable to create directories: %s", packDir.toString())); + } + + // extract each of the defined icons to the icon pack directory + for (IconPack.Icon icon : pack.getIcons()) { + File destFile = new File(packDir, icon.getRelativeFilename()); + FileHeader iconHeader = zipFile.getFileHeader(icon.getRelativeFilename()); + if (iconHeader == null) { + throw new IOException(String.format("Unable to find %s relative to the root of the ZIP file", icon.getRelativeFilename())); + } + + // create new directories for this file if needed + File parent = destFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException(String.format("Unable to create directories: %s", packDir.toString())); + } + + try (ZipInputStream inStream = zipFile.getInputStream(iconHeader); + FileOutputStream outStream = new FileOutputStream(destFile)) { + IOUtils.copy(inStream, outStream); + } + + // after successful copy of the icon, store the new filename + icon.setFile(destFile); + } + + // write the icon pack definition file to the newly created directory + try (FileOutputStream outStream = new FileOutputStream(new File(packDir, _packDefFilename))) { + outStream.write(defBytes); + } + + // after successful extraction of the icon pack, store the new directory + pack.setDirectory(packDir); + _iconPacks.add(pack); + return pack; + } catch (IOException | JSONException e) { + throw new IconPackException(e); + } + } + + private void rescanIconPacks() { + _iconPacks.clear(); + + File[] dirs = _iconsBaseDir.listFiles(); + if (dirs == null) { + return; + } + + for (File dir : dirs) { + if (!dir.isDirectory()) { + continue; + } + + UUID uuid; + try { + uuid = UUID.fromString(dir.getName()); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + continue; + } + + File versionDir = getLatestVersionDir(dir); + if (versionDir != null) { + IconPack pack; + try (FileInputStream inStream = new FileInputStream(new File(versionDir, _packDefFilename))) { + byte[] bytes = IOUtils.readAll(inStream); + pack = IconPack.fromBytes(bytes); + pack.setDirectory(versionDir); + } catch (JSONException | IOException e) { + e.printStackTrace(); + continue; + } + + for (IconPack.Icon icon : pack.getIcons()) { + icon.setFile(new File(versionDir, icon.getRelativeFilename())); + } + + // do a sanity check on the UUID and version + if (pack.getUUID().equals(uuid) && pack.getVersion() == Integer.parseInt(versionDir.getName())) { + _iconPacks.add(pack); + } + } + } + } + + private File getIconPackDir(IconPack pack) { + return new File(_iconsBaseDir, pack.getUUID() + File.separator + pack.getVersion()); + } + + @Nullable + private static File getLatestVersionDir(File packDir) { + File[] dirs = packDir.listFiles(); + if (dirs == null) { + return null; + } + + int latestVersion = -1; + for (File versionDir : dirs) { + int version; + try { + version = Integer.parseInt(versionDir.getName()); + } catch (NumberFormatException ignored) { + continue; + } + + if (latestVersion == -1 || version > latestVersion) { + latestVersion = version; + } + } + + if (latestVersion == -1) { + return null; + } + + return new File(packDir, Integer.toString(latestVersion)); + } + + private static void deleteDir(File dir) throws IOException { + if (dir.isDirectory()) { + File[] children = dir.listFiles(); + if (children != null) { + for (File child : children) { + deleteDir(child); + } + } + } + + if (!dir.delete()) { + throw new IOException(String.format("Unable to delete directory: %s", dir)); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java b/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java new file mode 100644 index 00000000..26ffa684 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/icons/IconType.java @@ -0,0 +1,52 @@ +package com.beemdevelopment.aegis.icons; + +import com.google.common.io.Files; + +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; + } + } + + @SuppressWarnings("UnstableApiUsage") + public static IconType fromFilename(String filename) { + switch (Files.getFileExtension(filename).toLowerCase()) { + case "svg": + return SVG; + case "png": + return PNG; + case "jpg": + // intentional fallthrough + case "jpeg": + return JPEG; + default: + return INVALID; + } + } + + public String toMimeType() { + switch (this) { + case SVG: + return "image/svg+xml"; + case PNG: + return "image/png"; + case JPEG: + return "image/jpeg"; + default: + throw new RuntimeException(String.format("Can't convert icon type %s to MIME type", this)); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java index 85b43f77..1b18ae6b 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java @@ -5,13 +5,12 @@ import android.content.DialogInterface; import androidx.lifecycle.Lifecycle; -import com.beemdevelopment.aegis.encoding.EncodingException; import com.beemdevelopment.aegis.helpers.ContextHelper; -import com.beemdevelopment.aegis.otp.OtpInfoException; 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; @@ -140,7 +139,7 @@ public class AegisImporter extends DatabaseImporter { private static VaultEntry convertEntry(JSONObject obj) throws DatabaseImporterEntryException { try { return VaultEntry.fromJson(obj); - } catch (JSONException | OtpInfoException | EncodingException e) { + } catch (VaultEntryException e) { throw new DatabaseImporterEntryException(e, obj.toString()); } } 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 b8d0da12..a5265f29 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java @@ -34,7 +34,10 @@ import com.beemdevelopment.aegis.encoding.Base32; import com.beemdevelopment.aegis.encoding.EncodingException; import com.beemdevelopment.aegis.helpers.DropdownHelper; import com.beemdevelopment.aegis.helpers.EditTextHelper; +import com.beemdevelopment.aegis.helpers.IconViewHelper; import com.beemdevelopment.aegis.helpers.TextDrawableHelper; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.icons.IconType; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.HotpInfo; import com.beemdevelopment.aegis.otp.OtpInfo; @@ -42,23 +45,32 @@ 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.dialogs.IconPickerDialog; +import com.beemdevelopment.aegis.ui.glide.IconLoader; +import com.beemdevelopment.aegis.ui.views.IconAdapter; import com.beemdevelopment.aegis.util.Cloner; +import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.vault.VaultManager; import com.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.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import java.io.ByteArrayOutputStream; +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.TreeSet; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import de.hdodenhof.circleimageview.CircleImageView; @@ -72,6 +84,7 @@ public class EditEntryActivity extends AegisActivity { 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 boolean _isEditingIcon; private CircleImageView _iconView; private ImageView _saveImageButton; @@ -165,9 +178,11 @@ public class EditEntryActivity extends AegisActivity { // fill the fields with values if possible if (_origEntry.hasIcon()) { + IconViewHelper.setLayerType(_iconView, _origEntry.getIconType()); Glide.with(this) .asDrawable() .load(_origEntry) + .set(IconLoader.ICON_TYPE, _origEntry.getIconType()) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(false) .into(_iconView); @@ -237,7 +252,7 @@ public class EditEntryActivity extends AegisActivity { }); _iconView.setOnClickListener(v -> { - startIconSelectionActivity(); + startIconSelection(); }); _dropdownGroup.setOnItemClickListener(new AdapterView.OnItemClickListener() { @@ -384,11 +399,13 @@ public class EditEntryActivity extends AegisActivity { }); break; case R.id.action_edit_icon: - startIconSelectionActivity(); + startIconSelection(); break; case R.id.action_default_icon: TextDrawable drawable = TextDrawableHelper.generate(_origEntry.getIssuer(), _origEntry.getName(), _iconView); _iconView.setImageDrawable(drawable); + + _selectedIcon = null; _hasCustomIcon = false; _hasChangedIcon = true; default: @@ -398,7 +415,7 @@ public class EditEntryActivity extends AegisActivity { return true; } - private void startIconSelectionActivity() { + private void startImageSelectionActivity() { Intent galleryIntent = new Intent(Intent.ACTION_PICK); galleryIntent.setDataAndType(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); @@ -410,6 +427,40 @@ public class EditEntryActivity extends AegisActivity { startActivityForResult(chooserIntent, PICK_IMAGE_REQUEST); } + private void startIconSelection() { + List iconPacks = getApp().getIconPackManager().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(), new IconAdapter.Listener() { + @Override + public void onIconSelected(IconPack.Icon icon) { + _selectedIcon = icon; + _hasCustomIcon = true; + _hasChangedIcon = true; + + IconViewHelper.setLayerType(_iconView, icon.getIconType()); + Glide.with(EditEntryActivity.this) + .asDrawable() + .load(icon.getFile()) + .set(IconLoader.ICON_TYPE, icon.getIconType()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(false) + .into(_iconView); + } + + @Override + public void onCustomSelected() { + startImageSelectionActivity(); + } + }); + Dialogs.showSecureDialog(dialog); + } + private void startEditingIcon(Uri data) { Glide.with(this) .asBitmap() @@ -438,11 +489,12 @@ public class EditEntryActivity extends AegisActivity { } private void stopEditingIcon(boolean save) { - if (save) { + if (save && _selectedIcon == null) { _iconView.setImageBitmap(_kropView.getCroppedBitmap()); } _iconView.setVisibility(View.VISIBLE); _kropView.setVisibility(View.GONE); + _hasCustomIcon = _hasCustomIcon || save; _hasChangedIcon = save; _isEditingIcon = false; @@ -577,13 +629,26 @@ public class EditEntryActivity extends AegisActivity { if (_hasChangedIcon) { if (_hasCustomIcon) { - Bitmap bitmap = ((BitmapDrawable) _iconView.getDrawable()).getBitmap(); - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); - byte[] data = stream.toByteArray(); - entry.setIcon(data); + 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(); + entry.setIcon(data, IconType.PNG); + } else { + byte[] iconBytes; + try (FileInputStream inStream = new FileInputStream(_selectedIcon.getFile())){ + iconBytes = IOUtils.readFile(inStream); + } catch (IOException e) { + // TODO: show dialog + throw new RuntimeException(e); + } + + entry.setIcon(iconBytes, _selectedIcon.getIconType()); + } } else { - entry.setIcon(null); + entry.setIcon(null, IconType.INVALID); } } @@ -626,7 +691,7 @@ public class EditEntryActivity extends AegisActivity { } } - private TextWatcher _iconChangeListener = new TextWatcher() { + private final TextWatcher _iconChangeListener = new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java index 5f93834c..c95fe452 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/PreferencesActivity.java @@ -4,6 +4,7 @@ import android.os.Bundle; import android.view.MenuItem; import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; @@ -13,7 +14,7 @@ import com.beemdevelopment.aegis.ui.fragments.PreferencesFragment; public class PreferencesActivity extends AegisActivity implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { - private PreferencesFragment _fragment; + private Fragment _fragment; @Override protected void onCreate(Bundle savedInstanceState) { @@ -58,18 +59,22 @@ public class PreferencesActivity extends AegisActivity implements @Override protected void onRestoreInstanceState(final Bundle inState) { - // pass the stored result intent back to the fragment - if (inState.containsKey("result")) { - _fragment.setResult(inState.getParcelable("result")); + if (_fragment instanceof PreferencesFragment) { + // pass the stored result intent back to the fragment + if (inState.containsKey("result")) { + ((PreferencesFragment) _fragment).setResult(inState.getParcelable("result")); + } } super.onRestoreInstanceState(inState); } @Override protected void onSaveInstanceState(final Bundle outState) { - // save the result intent of the fragment - // this is done so we don't lose anything if the fragment calls recreate on this activity - outState.putParcelable("result", _fragment.getResult()); + if (_fragment instanceof PreferencesFragment) { + // save the result intent of the fragment + // this is done so we don't lose anything if the fragment calls recreate on this activity + outState.putParcelable("result", ((PreferencesFragment) _fragment).getResult()); + } super.onSaveInstanceState(outState); } @@ -86,7 +91,7 @@ public class PreferencesActivity extends AegisActivity implements @Override public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) { - _fragment = (PreferencesFragment) getSupportFragmentManager().getFragmentFactory().instantiate(getClassLoader(), pref.getFragment()); + _fragment = getSupportFragmentManager().getFragmentFactory().instantiate(getClassLoader(), pref.getFragment()); _fragment.setArguments(pref.getExtras()); _fragment.setTargetFragment(caller, 0); showFragment(_fragment); @@ -95,7 +100,7 @@ public class PreferencesActivity extends AegisActivity implements return true; } - private void showFragment(PreferencesFragment fragment) { + 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) diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/IconPickerDialog.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/IconPickerDialog.java new file mode 100644 index 00000000..98ce004a --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/IconPickerDialog.java @@ -0,0 +1,157 @@ +package com.beemdevelopment.aegis.ui.dialogs; + +import android.app.Activity; +import android.graphics.drawable.Drawable; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.PopupMenu; +import androidx.recyclerview.widget.GridLayoutManager; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.ui.glide.IconLoader; +import com.beemdevelopment.aegis.ui.views.IconAdapter; +import com.beemdevelopment.aegis.ui.views.IconRecyclerView; +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.load.engine.DiskCacheStrategy; +import com.bumptech.glide.util.ViewPreloadSizeProvider; +import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.textfield.TextInputEditText; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class IconPickerDialog { + private IconPickerDialog() { + + } + + public static BottomSheetDialog create(Activity activity, List iconPacks, String issuer, IconAdapter.Listener listener) { + View view = LayoutInflater.from(activity).inflate(R.layout.dialog_icon_picker, null); + TextView textIconPack = view.findViewById(R.id.text_icon_pack); + + BottomSheetDialog dialog = new BottomSheetDialog(activity); + dialog.setContentView(view); + dialog.create(); + + FrameLayout rootView = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + rootView.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + + IconAdapter adapter = new IconAdapter(dialog.getContext(), issuer, new IconAdapter.Listener() { + @Override + public void onIconSelected(IconPack.Icon icon) { + dialog.dismiss(); + listener.onIconSelected(icon); + } + + @Override + public void onCustomSelected() { + dialog.dismiss(); + listener.onCustomSelected(); + } + }); + + class IconPreloadProvider implements ListPreloader.PreloadModelProvider { + @NonNull + @Override + public List getPreloadItems(int position) { + IconPack.Icon icon = adapter.getIconAt(position); + return Collections.singletonList(icon); + } + + @Nullable + @Override + public RequestBuilder getPreloadRequestBuilder(@NonNull IconPack.Icon icon) { + return Glide.with(dialog.getContext()) + .asDrawable() + .load(icon.getFile()) + .set(IconLoader.ICON_TYPE, icon.getIconType()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(false); + } + } + + TextInputEditText iconSearch = view.findViewById(R.id.text_search_icon); + iconSearch.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + BottomSheetBehavior behavior = BottomSheetBehavior.from(rootView); + behavior.setState(BottomSheetBehavior.STATE_EXPANDED); + } + }); + iconSearch.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + String query = s.toString(); + adapter.setQuery(query.isEmpty() ? null : query); + } + + @Override + public void afterTextChanged(Editable s) { + + } + }); + + ViewPreloadSizeProvider preloadSizeProvider = new ViewPreloadSizeProvider<>(); + IconPreloadProvider modelProvider = new IconPreloadProvider(); + RecyclerViewPreloader preloader = new RecyclerViewPreloader<>(activity, modelProvider, preloadSizeProvider, 10); + IconRecyclerView recyclerView = view.findViewById(R.id.list_icons); + GridLayoutManager layoutManager = recyclerView.getGridLayoutManager(); + layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + if (adapter.getItemViewType(position) == R.layout.card_icon) { + return 1; + } + + return recyclerView.getSpanCount(); + } + }); + recyclerView.setLayoutManager(layoutManager); + recyclerView.setAdapter(adapter); + recyclerView.addOnScrollListener(preloader); + adapter.loadIcons(iconPacks.get(0)); + textIconPack.setText(iconPacks.get(0).getName()); + + view.findViewById(R.id.btn_icon_pack).setOnClickListener(v -> { + List iconPackNames = iconPacks.stream() + .map(IconPack::getName) + .collect(Collectors.toList()); + + PopupMenu popupMenu = new PopupMenu(activity, v); + popupMenu.setOnMenuItemClickListener(item -> { + IconPack pack = iconPacks.get(iconPackNames.indexOf(item.getTitle().toString())); + adapter.loadIcons(pack); + adapter.setQuery(iconSearch.getText().toString()); + textIconPack.setText(pack.getName()); + return true; + }); + Menu menu = popupMenu.getMenu(); + for (String name : iconPackNames) { + menu.add(name); + } + + popupMenu.show(); + }); + + return dialog; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/IconPacksManagerFragment.java b/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/IconPacksManagerFragment.java new file mode 100644 index 00000000..46796812 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/IconPacksManagerFragment.java @@ -0,0 +1,159 @@ +package com.beemdevelopment.aegis.ui.fragments; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.AegisApplication; +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.FabScrollHelper; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.icons.IconPackException; +import com.beemdevelopment.aegis.icons.IconPackExistsException; +import com.beemdevelopment.aegis.icons.IconPackManager; +import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.tasks.ImportIconPackTask; +import com.beemdevelopment.aegis.ui.views.IconPackAdapter; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +public class IconPacksManagerFragment extends Fragment implements IconPackAdapter.Listener { + private static final int CODE_IMPORT = 0; + + private IconPackAdapter _adapter; + private IconPackManager _iconPackManager; + + private View _iconPacksView; + private RecyclerView _iconPacksRecyclerView; + private LinearLayout _noIconPacksView; + private FabScrollHelper _fabScrollHelper; + + public IconPacksManagerFragment() { + super(R.layout.fragment_icon_packs); + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + _iconPackManager = ((AegisApplication) getContext().getApplicationContext()).getIconPackManager(); + + FloatingActionButton fab = view.findViewById(R.id.fab); + fab.setOnClickListener(v -> startImportIconPack()); + _fabScrollHelper = new FabScrollHelper(fab); + + _noIconPacksView = view.findViewById(R.id.vEmptyList); + ((TextView) view.findViewById(R.id.txt_no_icon_packs)).setMovementMethod(LinkMovementMethod.getInstance()); + _iconPacksView = view.findViewById(R.id.view_icon_packs); + _adapter = new IconPackAdapter(this); + _iconPacksRecyclerView = view.findViewById(R.id.list_icon_packs); + LinearLayoutManager layoutManager = new LinearLayoutManager(getContext()); + _iconPacksRecyclerView.setLayoutManager(layoutManager); + _iconPacksRecyclerView.setAdapter(_adapter); + _iconPacksRecyclerView.setNestedScrollingEnabled(false); + _iconPacksRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + _fabScrollHelper.onScroll(dx, dy); + } + }); + + for (IconPack pack : _iconPackManager.getIconPacks()) { + _adapter.addIconPack(pack); + } + + updateEmptyState(); + } + + @Override + public void onRemoveIconPack(IconPack pack) { + Dialogs.showSecureDialog(new AlertDialog.Builder(getContext()) + .setTitle(R.string.remove_icon_pack) + .setMessage(R.string.remove_icon_pack_description) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + try { + _iconPackManager.removeIconPack(pack); + } catch (IconPackException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(getContext(), R.string.icon_pack_delete_error, e); + return; + } + _adapter.removeIconPack(pack); + updateEmptyState(); + }) + .setNegativeButton(android.R.string.no, null) + .create()); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == CODE_IMPORT && resultCode == Activity.RESULT_OK && data != null && data.getData() != null) { + importIconPack(data.getData()); + } + } + + private void importIconPack(Uri uri) { + ImportIconPackTask task = new ImportIconPackTask(getContext(), result -> { + Exception e = result.getException(); + if (e instanceof IconPackExistsException) { + Dialogs.showSecureDialog(new AlertDialog.Builder(getContext()) + .setTitle(R.string.error_occurred) + .setMessage(R.string.icon_pack_import_exists_error) + .setPositiveButton(R.string.yes, (dialog, which) -> { + if (removeIconPack(((IconPackExistsException) e).getIconPack())) { + importIconPack(uri); + } + }) + .setNegativeButton(R.string.no, null) + .create()); + } else if (e != null) { + Dialogs.showErrorDialog(getContext(), R.string.icon_pack_import_error, e); + } else { + _adapter.addIconPack(result.getIconPack()); + updateEmptyState(); + } + }); + task.execute(getLifecycle(), new ImportIconPackTask.Params(_iconPackManager, uri)); + } + + private boolean removeIconPack(IconPack pack) { + try { + _iconPackManager.removeIconPack(pack); + } catch (IconPackException e) { + e.printStackTrace(); + Dialogs.showErrorDialog(getContext(), R.string.icon_pack_delete_error, e); + return false; + } + + _adapter.removeIconPack(pack); + updateEmptyState(); + return true; + } + + private void startImportIconPack() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("application/zip"); + startActivityForResult(intent, CODE_IMPORT); + } + + private void updateEmptyState() { + if (_adapter.getItemCount() > 0) { + _iconPacksView.setVisibility(View.VISIBLE); + _noIconPacksView.setVisibility(View.GONE); + } else { + _iconPacksView.setVisibility(View.GONE); + _noIconPacksView.setVisibility(View.VISIBLE); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/AegisGlideModule.java b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/AegisGlideModule.java index db906a4d..b6de07b5 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/AegisGlideModule.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/AegisGlideModule.java @@ -1,6 +1,7 @@ package com.beemdevelopment.aegis.ui.glide; import android.content.Context; +import android.graphics.drawable.PictureDrawable; import androidx.annotation.NonNull; @@ -9,7 +10,9 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.Registry; import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.module.AppGlideModule; +import com.caverock.androidsvg.SVG; +import java.io.InputStream; import java.nio.ByteBuffer; @GlideModule @@ -17,5 +20,8 @@ public class AegisGlideModule extends AppGlideModule { @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { registry.prepend(VaultEntry.class, ByteBuffer.class, new IconLoader.Factory()); + registry.register(SVG.class, PictureDrawable.class, new SvgDrawableTranscoder()) + .append(InputStream.class, SVG.class, new SvgDecoder()) + .append(ByteBuffer.class, SVG.class, new SvgBytesDecoder()); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/IconLoader.java b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/IconLoader.java index 9e0143ca..2eb08036 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/IconLoader.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/IconLoader.java @@ -2,9 +2,11 @@ package com.beemdevelopment.aegis.ui.glide; import androidx.annotation.NonNull; +import com.beemdevelopment.aegis.icons.IconType; import com.beemdevelopment.aegis.vault.VaultEntry; import com.bumptech.glide.Priority; import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.Option; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.model.ModelLoader; @@ -14,6 +16,8 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory; import java.nio.ByteBuffer; public class IconLoader implements ModelLoader { + public static final Option ICON_TYPE = Option.memory("ICON_TYPE", IconType.INVALID); + @Override public LoadData buildLoadData(@NonNull VaultEntry model, int width, int height, @NonNull Options options) { return new LoadData<>(new UUIDKey(model.getUUID()), new Fetcher(model)); @@ -25,7 +29,7 @@ public class IconLoader implements ModelLoader { } public static class Fetcher implements DataFetcher { - private VaultEntry _model; + private final VaultEntry _model; private Fetcher(VaultEntry model) { _model = model; diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgBytesDecoder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgBytesDecoder.java new file mode 100644 index 00000000..fadcc981 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgBytesDecoder.java @@ -0,0 +1,29 @@ +package com.beemdevelopment.aegis.ui.glide; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.caverock.androidsvg.SVG; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +public class SvgBytesDecoder implements ResourceDecoder { + private SvgDecoder _decoder = new SvgDecoder(); + + @Override + public boolean handles(@NonNull ByteBuffer source, @NonNull Options options) throws IOException { + try (ByteArrayInputStream inStream = new ByteArrayInputStream(source.array())) { + return _decoder.handles(inStream, options); + } + } + + public Resource decode(@NonNull ByteBuffer source, int width, int height, @NonNull Options options) throws IOException { + try (ByteArrayInputStream inStream = new ByteArrayInputStream(source.array())) { + return _decoder.decode(inStream, width, height, options); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgDecoder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgDecoder.java new file mode 100644 index 00000000..2eac92ab --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgDecoder.java @@ -0,0 +1,44 @@ +package com.beemdevelopment.aegis.ui.glide; + +import androidx.annotation.NonNull; + +import com.beemdevelopment.aegis.icons.IconType; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGParseException; + +import java.io.IOException; +import java.io.InputStream; + +import static com.bumptech.glide.request.target.Target.SIZE_ORIGINAL; + +// source: https://github.com/bumptech/glide/tree/master/samples/svg/src/main/java/com/bumptech/glide/samples/svg + +/** Decodes an SVG internal representation from an {@link InputStream}. */ +public class SvgDecoder implements ResourceDecoder { + + @Override + public boolean handles(@NonNull InputStream source, @NonNull Options options) { + return options.get(IconLoader.ICON_TYPE) == IconType.SVG; + } + + public Resource decode( + @NonNull InputStream source, int width, int height, @NonNull Options options) + throws IOException { + try { + SVG svg = SVG.getFromInputStream(source); + if (width != SIZE_ORIGINAL) { + svg.setDocumentWidth(width); + } + if (height != SIZE_ORIGINAL) { + svg.setDocumentHeight(height); + } + return new SimpleResource<>(svg); + } catch (SVGParseException ex) { + throw new IOException("Cannot load SVG from stream", ex); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgDrawableTranscoder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgDrawableTranscoder.java new file mode 100644 index 00000000..ffa3ac29 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/glide/SvgDrawableTranscoder.java @@ -0,0 +1,30 @@ +package com.beemdevelopment.aegis.ui.glide; + +import android.graphics.Picture; +import android.graphics.drawable.PictureDrawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.SimpleResource; +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; +import com.caverock.androidsvg.SVG; + +// source: https://github.com/bumptech/glide/tree/master/samples/svg/src/main/java/com/bumptech/glide/samples/svg + +/** + * Convert the {@link SVG}'s internal representation to an Android-compatible one ({@link Picture}). + */ +public class SvgDrawableTranscoder implements ResourceTranscoder { + @Nullable + @Override + public Resource transcode( + @NonNull Resource toTranscode, @NonNull Options options) { + SVG svg = toTranscode.get(); + Picture picture = svg.renderToPicture(); + PictureDrawable drawable = new PictureDrawable(picture); + return new SimpleResource<>(drawable); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/ImportFileTask.java b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/ImportFileTask.java index 4bf97df9..602de544 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/ImportFileTask.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/ImportFileTask.java @@ -35,6 +35,7 @@ public class ImportFileTask extends ProgressDialogTask { + private final ImportIconPackTask.Callback _cb; + + public ImportIconPackTask(Context context, ImportIconPackTask.Callback cb) { + super(context, context.getString(R.string.importing_icon_pack)); + _cb = cb; + } + + @Override + protected ImportIconPackTask.Result doInBackground(ImportIconPackTask.Params... params) { + Context context = getDialog().getContext(); + ImportIconPackTask.Params param = params[0]; + + File tempFile = null; + try { + tempFile = File.createTempFile("icon-pack-", "", context.getCacheDir()); + try (InputStream inStream = context.getContentResolver().openInputStream(param.getUri()); + FileOutputStream outStream = new FileOutputStream(tempFile)) { + IOUtils.copy(inStream, outStream); + } + + IconPack pack = param.getManager().importPack(tempFile); + return new Result(pack, null); + } catch (IOException | IconPackException e) { + e.printStackTrace(); + return new ImportIconPackTask.Result(null, e); + } finally { + if (tempFile != null) { + tempFile.delete(); + } + } + } + + @Override + protected void onPostExecute(ImportIconPackTask.Result result) { + super.onPostExecute(result); + _cb.onTaskFinished(result); + } + + public interface Callback { + void onTaskFinished(ImportIconPackTask.Result result); + } + + public static class Params { + private final IconPackManager _manager; + private final Uri _uri; + + public Params(IconPackManager manager, Uri uri) { + _manager = manager; + _uri = uri; + } + + public IconPackManager getManager() { + return _manager; + } + + public Uri getUri() { + return _uri; + } + } + + public static class Result { + private final IconPack _pack; + private final Exception _e; + + public Result(IconPack pack, Exception e) { + _pack = pack; + _e = e; + } + + public IconPack getIconPack() { + return _pack; + } + + public Exception getException() { + return _e; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java index c438a208..1e4bad0a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryHolder.java @@ -14,6 +14,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.amulyakhare.textdrawable.TextDrawable; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.IconViewHelper; import com.beemdevelopment.aegis.helpers.TextDrawableHelper; import com.beemdevelopment.aegis.helpers.ThemeHelper; import com.beemdevelopment.aegis.helpers.UiRefresher; @@ -21,10 +22,10 @@ import com.beemdevelopment.aegis.otp.HotpInfo; import com.beemdevelopment.aegis.otp.OtpInfo; import com.beemdevelopment.aegis.otp.SteamInfo; import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.glide.IconLoader; import com.beemdevelopment.aegis.vault.VaultEntry; import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.google.zxing.common.StringUtils; public class EntryHolder extends RecyclerView.ViewHolder { private static final float DEFAULT_ALPHA = 1.0f; @@ -141,9 +142,11 @@ public class EntryHolder extends RecyclerView.ViewHolder { public void loadIcon(Fragment fragment) { if (_entry.hasIcon()) { + IconViewHelper.setLayerType(_profileDrawable, _entry.getIconType()); Glide.with(fragment) .asDrawable() .load(_entry) + .set(IconLoader.ICON_TYPE, _entry.getIconType()) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(false) .into(_profileDrawable); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryListView.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryListView.java index 55ac1941..de6d3404 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryListView.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryListView.java @@ -4,6 +4,7 @@ import android.annotation.SuppressLint; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.os.Bundle; import android.view.LayoutInflater; import android.view.MotionEvent; @@ -31,6 +32,7 @@ import com.beemdevelopment.aegis.helpers.SimpleItemTouchHelperCallback; import com.beemdevelopment.aegis.helpers.UiRefresher; import com.beemdevelopment.aegis.otp.TotpInfo; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.glide.IconLoader; import com.beemdevelopment.aegis.vault.VaultEntry; import com.bumptech.glide.Glide; import com.bumptech.glide.ListPreloader; @@ -486,10 +488,11 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { @Nullable @Override - public RequestBuilder getPreloadRequestBuilder(@NonNull VaultEntry entry) { + public RequestBuilder getPreloadRequestBuilder(@NonNull VaultEntry entry) { return Glide.with(EntryListView.this) .asDrawable() .load(entry) + .set(IconLoader.ICON_TYPE, entry.getIconType()) .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(false); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconAdapter.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconAdapter.java new file mode 100644 index 00000000..a35e7a01 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconAdapter.java @@ -0,0 +1,288 @@ +package com.beemdevelopment.aegis.ui.views; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.icons.IconType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class IconAdapter extends RecyclerView.Adapter { + private final Context _context; + private final String _issuer; + private final Listener _listener; + private IconPack _pack; + private List _icons; + private final List _categories; + private String _query; + + public IconAdapter(@NonNull Context context, String issuer, @NonNull Listener listener) { + _context = context; + _issuer = issuer; + _listener = listener; + _icons = new ArrayList<>(); + _categories = new ArrayList<>(); + } + + /** + * Loads all icons from the given icon pack into this adapter. Any icons added before this call will be overwritten. + */ + public void loadIcons(IconPack pack) { + _pack = pack; + _query = null; + _icons = new ArrayList<>(_pack.getIcons()); + _categories.clear(); + + Comparator iconCategoryComparator = (i1, i2) -> { + String c1 = getCategoryString(i1.getCategory()); + String c2 = getCategoryString(i2.getCategory()); + return c1.compareTo(c2); + }; + Collections.sort(_icons, iconCategoryComparator.thenComparing(IconPack.Icon::getName)); + + long categoryCount = _icons.stream() + .map(IconPack.Icon::getCategory) + .filter(Objects::nonNull) + .distinct() + .count(); + + List suggested = pack.getSuggestedIcons(_issuer); + suggested.add(0, new DummyIcon(_context.getString(R.string.icon_custom))); + if (suggested.size() > 0) { + CategoryHeader category = new CategoryHeader(_context.getString(R.string.suggested)); + category.setIsCollapsed(false); + category.getIcons().addAll(suggested); + _categories.add(category); + } + + CategoryHeader category = null; + for (IconPack.Icon icon : _icons) { + String iconCategory = getCategoryString(icon.getCategory()); + if (category == null || !getCategoryString(category.getCategory()).equals(iconCategory)) { + boolean collapsed = !(categoryCount == 0 && category == null); + category = new CategoryHeader(iconCategory); + category.setIsCollapsed(collapsed); + _categories.add(category); + } + + category.getIcons().add(icon); + } + + _icons.addAll(0, suggested); + updateCategoryPositions(); + notifyDataSetChanged(); + } + + public void setQuery(@Nullable String query) { + _query = query; + + if (_query == null) { + loadIcons(_pack); + } else { + _icons = _pack.getIcons().stream() + .filter(i -> i.isSuggestedFor(query)) + .collect(Collectors.toList()); + + Collections.sort(_icons, Comparator.comparing(IconPack.Icon::getName)); + notifyDataSetChanged(); + } + } + + public IconPack.Icon getIconAt(int position) { + if (isQueryActive()) { + return _icons.get(position); + } + + position = translateIconPosition(position); + return _icons.get(position); + } + + public CategoryHeader getCategoryAt(int position) { + return _categories.stream() + .filter(c -> c.getPosition() == position) + .findFirst() + .orElse(null); + } + + private String getCategoryString(String category) { + return category == null ? _context.getString(R.string.uncategorized) : category; + } + + private boolean isCategoryPosition(int position) { + if (isQueryActive()) { + return false; + } + + return getCategoryAt(position) != null; + } + + private int translateIconPosition(int position) { + int offset = 0; + for (CategoryHeader category : _categories) { + if (category.getPosition() < position) { + offset++; + if (category.isCollapsed()) { + offset -= category.getIcons().size(); + } + } + } + + return position - offset; + } + + private void updateCategoryPositions() { + int i = 0; + for (CategoryHeader category : _categories) { + category.setPosition(i); + + int icons = 0; + if (!category.isCollapsed()) { + icons = category.getIcons().size(); + } + + i += 1 + icons; + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false); + return viewType == R.layout.card_icon ? new IconHolder(view) : new IconCategoryHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + if (!isCategoryPosition(position)) { + IconHolder iconHolder = (IconHolder) holder; + IconPack.Icon icon = getIconAt(position); + iconHolder.setData(icon); + iconHolder.loadIcon(_context); + iconHolder.itemView.setOnClickListener(v -> { + if (icon instanceof DummyIcon) { + _listener.onCustomSelected(); + } else { + _listener.onIconSelected(icon); + } + }); + } else { + IconCategoryHolder categoryHolder = (IconCategoryHolder) holder; + CategoryHeader category = getCategoryAt(position); + categoryHolder.setData(category); + categoryHolder.itemView.setOnClickListener(v -> { + boolean collapsed = !category.isCollapsed(); + categoryHolder.setIsCollapsed(collapsed); + category.setIsCollapsed(collapsed); + + int startPosition = category.getPosition() + 1; + if (category.isCollapsed()) { + notifyItemRangeRemoved(startPosition, category.getIcons().size()); + } else { + notifyItemRangeInserted(startPosition, category.getIcons().size()); + } + + updateCategoryPositions(); + }); + } + } + + @Override + public int getItemCount() { + if (isQueryActive()) { + return _icons.size(); + } + + int items = _categories.stream() + .filter(c -> !c.isCollapsed()) + .mapToInt(c -> c.getIcons().size()) + .sum(); + + return items + _categories.size(); + } + + @Override + public int getItemViewType(int position) { + if (isCategoryPosition(position)) { + return R.layout.card_icon_category; + } + + return R.layout.card_icon; + } + + private boolean isQueryActive() { + return _query != null; + } + + public interface Listener { + void onIconSelected(IconPack.Icon icon); + void onCustomSelected(); + } + + public static class DummyIcon extends IconPack.Icon { + private final String _name; + + protected DummyIcon(String name) { + super(null, null, null); + _name = name; + } + + @Override + public String getName() { + return _name; + } + + @Override + public IconType getIconType() { + return null; + } + } + + public static class CategoryHeader { + private final String _category; + private int _position = -1; + private final List _icons; + private boolean _collapsed = true; + + public CategoryHeader(String category) { + _category = category; + _icons = new ArrayList<>(); + } + + public String getCategory() { + return _category; + } + + public int getPosition() { + return _position; + } + + public void setPosition(int position) { + _position = position; + } + + public List getIcons() { + return _icons; + } + + public boolean isCollapsed() { + return _collapsed; + } + + public void setIsCollapsed(boolean collapsed) { + _collapsed = collapsed; + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconCategoryHolder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconCategoryHolder.java new file mode 100644 index 00000000..1c5d0afc --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconCategoryHolder.java @@ -0,0 +1,36 @@ +package com.beemdevelopment.aegis.ui.views; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; + +public class IconCategoryHolder extends RecyclerView.ViewHolder { + private final TextView _textView; + private final ImageView _imgView; + + public IconCategoryHolder(final View view) { + super(view); + _textView = view.findViewById(R.id.icon_category); + _imgView = view.findViewById(R.id.icon_category_indicator); + } + + public void setData(IconAdapter.CategoryHeader header) { + _textView.setText(header.getCategory()); + _imgView.setRotation(getRotation(header.isCollapsed())); + } + + public void setIsCollapsed(boolean collapsed) { + _imgView.animate() + .setDuration(200) + .rotation(getRotation(collapsed)) + .start(); + } + + private static int getRotation(boolean collapsed) { + return collapsed ? 90 : 0; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconHolder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconHolder.java new file mode 100644 index 00000000..a583d3fe --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconHolder.java @@ -0,0 +1,59 @@ +package com.beemdevelopment.aegis.ui.views; + +import android.content.Context; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.IconViewHelper; +import com.beemdevelopment.aegis.helpers.ThemeHelper; +import com.beemdevelopment.aegis.icons.IconPack; +import com.beemdevelopment.aegis.icons.IconType; +import com.beemdevelopment.aegis.ui.glide.IconLoader; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import java.io.File; + +public class IconHolder extends RecyclerView.ViewHolder { + private File _iconFile; + private IconType _iconType; + private boolean _isCustom; + + private final ImageView _imageView; + private final TextView _textView; + + public IconHolder(final View view) { + super(view); + _imageView = view.findViewById(R.id.icon); + _textView = view.findViewById(R.id.icon_name); + } + + public void setData(IconPack.Icon icon) { + _iconFile = icon.getFile(); + _iconType = icon.getIconType(); + _isCustom = icon instanceof IconAdapter.DummyIcon; + _textView.setText(icon.getName()); + } + + public void loadIcon(Context context) { + if (_isCustom) { + int tint = ThemeHelper.getThemeColor(R.attr.iconColorPrimary, context.getTheme()); + _imageView.setColorFilter(tint); + _imageView.setImageResource(R.drawable.ic_plus_black_24dp); + } else { + _imageView.setImageTintList(null); + IconViewHelper.setLayerType(_imageView, _iconType); + Glide.with(context) + .asDrawable() + .load(_iconFile) + .set(IconLoader.ICON_TYPE, _iconType) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(false) + .into(_imageView); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconPackAdapter.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconPackAdapter.java new file mode 100644 index 00000000..ec232854 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconPackAdapter.java @@ -0,0 +1,66 @@ +package com.beemdevelopment.aegis.ui.views; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.icons.IconPack; + +import java.util.ArrayList; +import java.util.List; + +public class IconPackAdapter extends RecyclerView.Adapter { + private IconPackAdapter.Listener _listener; + private List _iconPacks; + + public IconPackAdapter(IconPackAdapter.Listener listener) { + _listener = listener; + _iconPacks = new ArrayList<>(); + } + + public void addIconPack(IconPack pack) { + _iconPacks.add(pack); + + int position = getItemCount() - 1; + if (position == 0) { + notifyDataSetChanged(); + } else { + notifyItemInserted(position); + } + } + + public void removeIconPack(IconPack pack) { + int position = _iconPacks.indexOf(pack); + _iconPacks.remove(position); + notifyItemRemoved(position); + } + + @NonNull + @Override + public IconPackHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.card_icon_pack, parent, false); + return new IconPackHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull IconPackHolder holder, int position) { + holder.setData(_iconPacks.get(position)); + holder.setOnDeleteClickListener(v -> { + int position12 = holder.getAdapterPosition(); + _listener.onRemoveIconPack(_iconPacks.get(position12)); + }); + } + + @Override + public int getItemCount() { + return _iconPacks.size(); + } + + public interface Listener { + void onRemoveIconPack(IconPack pack); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconPackHolder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconPackHolder.java new file mode 100644 index 00000000..63a27946 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconPackHolder.java @@ -0,0 +1,32 @@ +package com.beemdevelopment.aegis.ui.views; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.icons.IconPack; + +public class IconPackHolder extends RecyclerView.ViewHolder { + private final TextView _iconPackName; + private final TextView _iconPackInfo; + private final ImageView _buttonDelete; + + public IconPackHolder(final View view) { + super(view); + _iconPackName = view.findViewById(R.id.text_icon_pack_name); + _iconPackInfo = view.findViewById(R.id.text_icon_pack_info); + _buttonDelete = view.findViewById(R.id.button_delete); + } + + public void setData(IconPack pack) { + _iconPackName.setText(String.format("%s (v%d)", pack.getName(), pack.getVersion())); + _iconPackInfo.setText(itemView.getResources().getQuantityString(R.plurals.icon_pack_info, pack.getIcons().size(), pack.getIcons().size())); + } + + public void setOnDeleteClickListener(View.OnClickListener listener) { + _buttonDelete.setOnClickListener(listener); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconRecyclerView.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconRecyclerView.java new file mode 100644 index 00000000..158aca9f --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/IconRecyclerView.java @@ -0,0 +1,63 @@ +package com.beemdevelopment.aegis.ui.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +// source (slightly modified for Aegis): https://github.com/chiuki/android-recyclerview/blob/745dc88/app/src/main/java/com/sqisland/android/recyclerview/AutofitRecyclerView.java +public class IconRecyclerView extends RecyclerView { + private GridLayoutManager _manager; + private int _columnWidth = -1; + private int _spanCount; + + public IconRecyclerView(@NonNull Context context) { + super(context); + init(context, null); + } + + public IconRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public IconRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + if (attrs != null) { + int[] attrsArray = { + android.R.attr.columnWidth + }; + TypedArray array = context.obtainStyledAttributes(attrs, attrsArray); + _columnWidth = array.getDimensionPixelSize(0, -1); + array.recycle(); + } + + _manager = new GridLayoutManager(getContext(), 1); + setLayoutManager(_manager); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + super.onMeasure(widthSpec, heightSpec); + if (_columnWidth > 0) { + _spanCount = Math.max(1, getMeasuredWidth() / _columnWidth); + _manager.setSpanCount(_spanCount); + } + } + + public GridLayoutManager getGridLayoutManager() { + return _manager; + } + + public int getSpanCount() { + return _spanCount; + } +} 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 99b90732..1800dbb9 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java @@ -1,7 +1,5 @@ package com.beemdevelopment.aegis.vault; -import com.beemdevelopment.aegis.encoding.EncodingException; -import com.beemdevelopment.aegis.otp.OtpInfoException; import com.beemdevelopment.aegis.util.UUIDMap; import org.json.JSONArray; @@ -9,7 +7,7 @@ import org.json.JSONException; import org.json.JSONObject; public class Vault { - private static final int VERSION = 1; + private static final int VERSION = 2; private UUIDMap _entries = new UUIDMap<>(); public JSONObject toJson() { @@ -34,7 +32,7 @@ public class Vault { try { int ver = obj.getInt("version"); - if (ver != VERSION) { + if (ver > VERSION) { throw new VaultException("Unsupported version"); } @@ -43,7 +41,7 @@ public class Vault { VaultEntry entry = VaultEntry.fromJson(array.getJSONObject(i)); entries.add(entry); } - } catch (EncodingException | OtpInfoException | JSONException e) { + } catch (VaultEntryException | JSONException e) { throw new VaultException(e); } 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 df6d6b3e..49d42637 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java @@ -2,10 +2,12 @@ package com.beemdevelopment.aegis.vault; import com.beemdevelopment.aegis.encoding.Base64; import com.beemdevelopment.aegis.encoding.EncodingException; +import com.beemdevelopment.aegis.icons.IconType; 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.util.JsonUtils; import com.beemdevelopment.aegis.util.UUIDMap; import org.json.JSONException; @@ -21,6 +23,7 @@ public class VaultEntry extends UUIDMap.Value { private String _group; private OtpInfo _info; private byte[] _icon; + private IconType _iconType = IconType.INVALID; private VaultEntry(UUID uuid, OtpInfo info) { super(uuid); @@ -59,6 +62,7 @@ public class VaultEntry extends UUIDMap.Value { obj.put("issuer", _issuer); obj.put("group", _group); obj.put("icon", _icon == null ? JSONObject.NULL : Base64.encode(_icon)); + obj.put("icon_mime", _icon == null ? null : _iconType.toMimeType()); obj.put("info", _info.toJson()); } catch (JSONException e) { throw new RuntimeException(e); @@ -67,27 +71,39 @@ public class VaultEntry extends UUIDMap.Value { return obj; } - public static VaultEntry fromJson(JSONObject obj) throws JSONException, OtpInfoException, EncodingException { - // if there is no uuid, generate a new one - UUID uuid; - if (!obj.has("uuid")) { - uuid = UUID.randomUUID(); - } else { - uuid = UUID.fromString(obj.getString("uuid")); + public static VaultEntry fromJson(JSONObject obj) throws VaultEntryException { + try { + // if there is no uuid, generate a new one + UUID uuid; + if (!obj.has("uuid")) { + uuid = UUID.randomUUID(); + } else { + uuid = UUID.fromString(obj.getString("uuid")); + } + + OtpInfo info = OtpInfo.fromJson(obj.getString("type"), obj.getJSONObject("info")); + VaultEntry entry = new VaultEntry(uuid, info); + entry.setName(obj.getString("name")); + entry.setIssuer(obj.getString("issuer")); + entry.setGroup(obj.optString("group", null)); + + Object icon = obj.get("icon"); + if (icon != JSONObject.NULL) { + 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)); + } + + byte[] iconBytes = Base64.decode((String) icon); + entry.setIcon(iconBytes, iconType); + } + + return entry; + } catch (OtpInfoException | JSONException | EncodingException e) { + throw new VaultEntryException(e); } - - OtpInfo info = OtpInfo.fromJson(obj.getString("type"), obj.getJSONObject("info")); - VaultEntry entry = new VaultEntry(uuid, info); - entry.setName(obj.getString("name")); - entry.setIssuer(obj.getString("issuer")); - entry.setGroup(obj.optString("group", null)); - - Object icon = obj.get("icon"); - if (icon != JSONObject.NULL) { - entry.setIcon(Base64.decode((String) icon)); - } - - return entry; } public String getName() { @@ -106,6 +122,10 @@ public class VaultEntry extends UUIDMap.Value { return _icon; } + public IconType getIconType() { + return _iconType; + } + public OtpInfo getInfo() { return _info; } @@ -126,8 +146,9 @@ public class VaultEntry extends UUIDMap.Value { _info = info; } - public void setIcon(byte[] icon) { + public void setIcon(byte[] icon, IconType iconType) { _icon = icon; + _iconType = iconType; } public boolean hasIcon() { @@ -154,7 +175,8 @@ public class VaultEntry extends UUIDMap.Value { && getIssuer().equals(entry.getIssuer()) && Objects.equals(getGroup(), entry.getGroup()) && getInfo().equals(entry.getInfo()) - && Arrays.equals(getIcon(), entry.getIcon()); + && Arrays.equals(getIcon(), entry.getIcon()) + && getIconType().equals(entry.getIconType()); } /** diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryException.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryException.java new file mode 100644 index 00000000..b14c3ede --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntryException.java @@ -0,0 +1,11 @@ +package com.beemdevelopment.aegis.vault; + +public class VaultEntryException extends Exception { + public VaultEntryException(Throwable cause) { + super(cause); + } + + public VaultEntryException(String message) { + super(message); + } +} diff --git a/app/src/main/res/drawable/ic_chevron_down_black_24dp.xml b/app/src/main/res/drawable/ic_chevron_down_black_24dp.xml new file mode 100644 index 00000000..73334a0e --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_down_black_24dp.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_dots_vertical_black_24dp.xml b/app/src/main/res/drawable/ic_dots_vertical_black_24dp.xml new file mode 100644 index 00000000..9a6a9845 --- /dev/null +++ b/app/src/main/res/drawable/ic_dots_vertical_black_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_package_variant_black_24dp.xml b/app/src/main/res/drawable/ic_package_variant_black_24dp.xml new file mode 100644 index 00000000..ee927181 --- /dev/null +++ b/app/src/main/res/drawable/ic_package_variant_black_24dp.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/card_icon.xml b/app/src/main/res/layout/card_icon.xml new file mode 100644 index 00000000..099285ab --- /dev/null +++ b/app/src/main/res/layout/card_icon.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/layout/card_icon_category.xml b/app/src/main/res/layout/card_icon_category.xml new file mode 100644 index 00000000..517f1ff5 --- /dev/null +++ b/app/src/main/res/layout/card_icon_category.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/layout/card_icon_pack.xml b/app/src/main/res/layout/card_icon_pack.xml new file mode 100644 index 00000000..c8d4812f --- /dev/null +++ b/app/src/main/res/layout/card_icon_pack.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_icon_picker.xml b/app/src/main/res/layout/dialog_icon_picker.xml new file mode 100644 index 00000000..6cd50396 --- /dev/null +++ b/app/src/main/res/layout/dialog_icon_picker.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_icon_packs.xml b/app/src/main/res/layout/fragment_icon_packs.xml new file mode 100644 index 00000000..4d635d27 --- /dev/null +++ b/app/src/main/res/layout/fragment_icon_packs.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04f81c64..ae7c8538 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Discard Save Issuer + Suggested Preferences App @@ -29,6 +30,8 @@ Import backups of Aegis or other authenticator apps. Create manual exports of your Aegis vault. Backups Set up automatic backups to a location of your choosing or enable participation in Android\'s backup system. + Icon packs + Manage and import icon packs Theme View mode Language @@ -149,6 +152,7 @@ Encrypting the vault Exporting the vault Reading file + Importing icon pack Delete entry Are you sure you want to delete this entry? This action does not disable 2FA for %s. To prevent losing access, make sure that you have disabled 2FA or that you have an alternative way to generate codes for this service. @@ -185,6 +189,15 @@ The backup was scheduled successfully An error occurred while trying to create a backup DocumentsUI appears to be missing from your device. This is an important system component necessary for the selection and creation of documents. If you used a tool to "debloat" your device, you may have accidentally deleted it and will have to reinstall it. + An error occurred while trying to import an icon pack + The icon pack you\'re trying to import already exists. Do you want to overwrite it? + An error occurred while trying to delete an icon pack + + %d icon + %d icons + + Icon pack + Custom Permission denied New format (v0.6.3 or newer) Old format (v0.6.2 or older) @@ -219,6 +232,8 @@ Are you sure you want to remove this slot? Remove group Are you sure you want to remove this group? Entries in this group will automatically switch to \'No group\'. + Remove icon pack + Are you sure you want to remove this icon pack? Entries that use icons from this pack will not be affected. An error occurred while trying to add a new slot: Unable to reset animator duration scale. Progress bars will be invisible. Details @@ -237,6 +252,7 @@ Group name Edit groups Manage and delete your groups here + Highlight tokens when tapped Make tokens easier to distinguish from each other by temporarily highlighting them when tapped Copy tokens when tapped @@ -320,6 +336,10 @@ No entries found There are no groups to be shown. Add groups in the edit screen of an entry No groups found + No icon packs have been imported yet. Tap the plus sign to import one. Tip: try krisu5\'s icon pack. + No icon packs + Pick an icon + Uncategorized Done %d / %d entry diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index def28dad..74dc4738 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -202,6 +202,7 @@ diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 09073a5d..3e463b81 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -16,6 +16,12 @@ app:title="@string/pref_section_behavior_title" app:summary="@string/pref_section_behavior_summary" /> + +