Merge pull request #539 from alexbakker/iconpacks

Introduce support for icon packs
This commit is contained in:
Alexander Bakker 2021-04-10 10:50:36 +02:00 committed by GitHub
commit ae0b00573f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 2128 additions and 54 deletions

View file

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

View file

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

View file

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

View file

@ -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<Icon> _icons;
private File _dir;
private IconPack(UUID uuid, String name, int version, List<Icon> 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<Icon> getIcons() {
return Collections.unmodifiableList(_icons);
}
/**
* Retrieves a list of icons suggested for the given issuer.
*/
public List<Icon> 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<Icon> 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<String> _issuers;
private File _file;
protected Icon(String filename, String category, List<String> 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<String> 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<String> 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);
}
}
}

View file

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

View file

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

View file

@ -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<IconPack> _iconPacks;
public IconPackManager(Context context) {
_iconPacks = new ArrayList<>();
_iconsBaseDir = new File(context.getFilesDir(), "icons");
rescanIconPacks();
}
private IconPack getIconPackByUUID(UUID uuid) {
List<IconPack> packs = _iconPacks.stream().filter(i -> i.getUUID().equals(uuid)).collect(Collectors.toList());
if (packs.size() == 0) {
return null;
}
return packs.get(0);
}
public List<IconPack> 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));
}
}
}

View file

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

View file

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

View file

@ -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<IconPack> 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) {
}

View file

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

View file

@ -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<IconPack> 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<IconPack.Icon> {
@NonNull
@Override
public List<IconPack.Icon> getPreloadItems(int position) {
IconPack.Icon icon = adapter.getIconAt(position);
return Collections.singletonList(icon);
}
@Nullable
@Override
public RequestBuilder<Drawable> 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<FrameLayout> 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<IconPack.Icon> preloadSizeProvider = new ViewPreloadSizeProvider<>();
IconPreloadProvider modelProvider = new IconPreloadProvider();
RecyclerViewPreloader<IconPack.Icon> 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<String> 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;
}
}

View file

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

View file

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

View file

@ -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<VaultEntry, ByteBuffer> {
public static final Option<IconType> ICON_TYPE = Option.memory("ICON_TYPE", IconType.INVALID);
@Override
public LoadData<ByteBuffer> 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<VaultEntry, ByteBuffer> {
}
public static class Fetcher implements DataFetcher<ByteBuffer> {
private VaultEntry _model;
private final VaultEntry _model;
private Fetcher(VaultEntry model) {
_model = model;

View file

@ -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<ByteBuffer, SVG> {
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<SVG> 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);
}
}
}

View file

@ -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<InputStream, SVG> {
@Override
public boolean handles(@NonNull InputStream source, @NonNull Options options) {
return options.get(IconLoader.ICON_TYPE) == IconType.SVG;
}
public Resource<SVG> 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);
}
}
}

View file

@ -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<SVG, PictureDrawable> {
@Nullable
@Override
public Resource<PictureDrawable> transcode(
@NonNull Resource<SVG> toTranscode, @NonNull Options options) {
SVG svg = toTranscode.get();
Picture picture = svg.renderToPicture();
PictureDrawable drawable = new PictureDrawable(picture);
return new SimpleResource<>(drawable);
}
}

View file

@ -35,6 +35,7 @@ public class ImportFileTask extends ProgressDialogTask<Uri, ImportFileTask.Resul
return new Result(tempFile, null);
} catch (IOException e) {
e.printStackTrace();
return new Result(null, e);
}
}

View file

@ -0,0 +1,95 @@
package com.beemdevelopment.aegis.ui.tasks;
import android.content.Context;
import android.net.Uri;
import com.beemdevelopment.aegis.R;
import com.beemdevelopment.aegis.icons.IconPack;
import com.beemdevelopment.aegis.icons.IconPackException;
import com.beemdevelopment.aegis.icons.IconPackManager;
import com.beemdevelopment.aegis.util.IOUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
public class ImportIconPackTask extends ProgressDialogTask<ImportIconPackTask.Params, ImportIconPackTask.Result> {
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;
}
}
}

View file

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

View file

@ -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<Drawable> getPreloadRequestBuilder(@NonNull VaultEntry entry) {
return Glide.with(EntryListView.this)
.asDrawable()
.load(entry)
.set(IconLoader.ICON_TYPE, entry.getIconType())
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(false);
}

View file

@ -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<RecyclerView.ViewHolder> {
private final Context _context;
private final String _issuer;
private final Listener _listener;
private IconPack _pack;
private List<IconPack.Icon> _icons;
private final List<CategoryHeader> _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<IconPack.Icon> 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<IconPack.Icon> 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<IconPack.Icon> _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<IconPack.Icon> getIcons() {
return _icons;
}
public boolean isCollapsed() {
return _collapsed;
}
public void setIsCollapsed(boolean collapsed) {
_collapsed = collapsed;
}
}
}

View file

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

View file

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

View file

@ -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<IconPackHolder> {
private IconPackAdapter.Listener _listener;
private List<IconPack> _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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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