diff --git a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java index e2339212..3bf8d965 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/Preferences.java +++ b/app/src/main/java/com/beemdevelopment/aegis/Preferences.java @@ -20,9 +20,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; public class Preferences { @@ -478,26 +480,26 @@ public class Preferences { return _prefs.getBoolean("pref_minimize_on_copy", false); } - public void setGroupFilter(List groupFilter) { + public void setGroupFilter(Set groupFilter) { JSONArray json = new JSONArray(groupFilter); - _prefs.edit().putString("pref_group_filter", json.toString()).apply(); + _prefs.edit().putString("pref_group_filter_uuids", json.toString()).apply(); } - public List getGroupFilter() { - String raw = _prefs.getString("pref_group_filter", null); + public Set getGroupFilter() { + String raw = _prefs.getString("pref_group_filter_uuids", null); if (raw == null || raw.isEmpty()) { - return Collections.emptyList(); + return Collections.emptySet(); } try { JSONArray json = new JSONArray(raw); - List filter = new ArrayList<>(); + Set filter = new HashSet<>(); for (int i = 0; i < json.length(); i++) { - filter.add(json.isNull(i) ? null : json.optString(i)); + filter.add(json.isNull(i) ? null : UUID.fromString(json.getString(i))); } return filter; } catch (JSONException e) { - return Collections.emptyList(); + return Collections.emptySet(); } } 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 c01b13df..ac42cc87 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/AegisImporter.java @@ -16,6 +16,7 @@ import com.beemdevelopment.aegis.vault.VaultEntryException; import com.beemdevelopment.aegis.vault.VaultFile; import com.beemdevelopment.aegis.vault.VaultFileCredentials; import com.beemdevelopment.aegis.vault.VaultFileException; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; import com.beemdevelopment.aegis.vault.slots.SlotList; import com.topjohnwu.superuser.io.SuFile; @@ -27,6 +28,7 @@ import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; import java.util.List; +import java.util.UUID; public class AegisImporter extends DatabaseImporter { @@ -132,11 +134,31 @@ public class AegisImporter extends DatabaseImporter { Result result = new Result(); try { - JSONArray array = _obj.getJSONArray("entries"); - for (int i = 0; i < array.length(); i++) { - JSONObject entryObj = array.getJSONObject(i); + if (_obj.has("groups")) { + JSONArray groupArray = _obj.getJSONArray("groups"); + for (int i = 0; i < groupArray.length(); i++) { + JSONObject groupObj = groupArray.getJSONObject(i); + try { + VaultGroup group = convertGroup(groupObj); + if (!result.getGroups().has(group)) { + result.addGroup(group); + } + } catch (DatabaseImporterEntryException e) { + result.addError(e); + } + } + } + + JSONArray entryArray = _obj.getJSONArray("entries"); + for (int i = 0; i < entryArray.length(); i++) { + JSONObject entryObj = entryArray.getJSONObject(i); try { VaultEntry entry = convertEntry(entryObj); + for (UUID groupUuid : entry.getGroups()) { + if (!result.getGroups().has(groupUuid)) { + entry.getGroups().remove(groupUuid); + } + } result.addEntry(entry); } catch (DatabaseImporterEntryException e) { result.addError(e); @@ -156,5 +178,13 @@ public class AegisImporter extends DatabaseImporter { throw new DatabaseImporterEntryException(e, obj.toString()); } } + + private static VaultGroup convertGroup(JSONObject obj) throws DatabaseImporterEntryException { + try { + return VaultGroup.fromJson(obj); + } catch (VaultEntryException e) { + throw new DatabaseImporterEntryException(e, obj.toString()); + } + } } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java index f3a9de4d..fcf8e8d4 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/importers/DatabaseImporter.java @@ -8,6 +8,7 @@ import androidx.annotation.StringRes; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.topjohnwu.superuser.Shell; import com.topjohnwu.superuser.io.SuFile; import com.topjohnwu.superuser.io.SuFileInputStream; @@ -168,12 +169,17 @@ public abstract class DatabaseImporter { public static class Result { private UUIDMap _entries = new UUIDMap<>(); + private UUIDMap _groups = new UUIDMap<>(); private List _errors = new ArrayList<>(); public void addEntry(VaultEntry entry) { _entries.add(entry); } + public void addGroup(VaultGroup group) { + _groups.add(group); + } + public void addError(DatabaseImporterEntryException error) { _errors.add(error); } @@ -182,6 +188,10 @@ public abstract class DatabaseImporter { return _entries; } + public UUIDMap getGroups() { + return _groups; + } + public List getErrors() { return _errors; } 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 8812592b..e58e2070 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/EditEntryActivity.java @@ -1,7 +1,7 @@ package com.beemdevelopment.aegis.ui; +import android.content.DialogInterface; import android.content.Intent; -import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; @@ -15,7 +15,6 @@ import android.view.ViewGroup; import android.view.animation.AccelerateInterpolator; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; -import android.widget.AdapterView; import android.widget.AutoCompleteTextView; import android.widget.ImageView; import android.widget.LinearLayout; @@ -54,11 +53,13 @@ import com.beemdevelopment.aegis.otp.YandexInfo; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.dialogs.IconPickerDialog; import com.beemdevelopment.aegis.ui.glide.IconLoader; +import com.beemdevelopment.aegis.ui.models.VaultGroupModel; import com.beemdevelopment.aegis.ui.tasks.ImportFileTask; import com.beemdevelopment.aegis.ui.views.IconAdapter; import com.beemdevelopment.aegis.util.Cloner; import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.beemdevelopment.aegis.vault.VaultRepository; import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; @@ -73,11 +74,14 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Locale; -import java.util.TreeSet; +import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -90,7 +94,7 @@ public class EditEntryActivity extends AegisActivity { private boolean _isNew = false; private boolean _isManual = false; private VaultEntry _origEntry; - private TreeSet _groups; + private Collection _groups; private boolean _hasCustomIcon = false; // keep track of icon changes separately as the generated jpeg's are not deterministic private boolean _hasChangedIcon = false; @@ -114,7 +118,7 @@ public class EditEntryActivity extends AegisActivity { private AutoCompleteTextView _dropdownAlgo; private TextInputLayout _dropdownAlgoLayout; private AutoCompleteTextView _dropdownGroup; - private List _dropdownGroupList = new ArrayList<>(); + private List _dropdownGroupList = new ArrayList<>(); private KropView _kropView; @@ -262,8 +266,13 @@ public class EditEntryActivity extends AegisActivity { updateAdvancedFieldStatus(_origEntry.getInfo().getTypeId()); updatePinFieldVisibility(_origEntry.getInfo().getTypeId()); - String group = _origEntry.getGroup(); - setGroup(group); + Set groups = _origEntry.getGroups(); + if (groups.isEmpty()) { + setGroup(new VaultGroupModel(getString(R.string.no_group))); + } else { + VaultGroup group = _vaultManager.getVault().getGroupByUUID(groups.iterator().next()); + setGroup(new VaultGroupModel(group)); + } // Update the icon if the issuer or name has changed _textIssuer.addTextChangedListener(_nameChangeListener); @@ -327,24 +336,31 @@ public class EditEntryActivity extends AegisActivity { startIconSelection(); }); - _dropdownGroup.setOnItemClickListener(new AdapterView.OnItemClickListener() { - private int prevPosition = _dropdownGroupList.indexOf(_dropdownGroup.getText().toString()); - - @Override - public void onItemClick(AdapterView parent, View view, int position, long id) { - if (position == _dropdownGroupList.size() - 1) { - Dialogs.showTextInputDialog(EditEntryActivity.this, R.string.set_group, R.string.group_name_hint, text -> { - String groupName = new String(text); - if (!groupName.isEmpty()) { - _groups.add(groupName); - updateGroupDropdownList(); - _dropdownGroup.setText(groupName, false); + _dropdownGroup.setOnItemClickListener((parent, view, position, id) -> { + VaultGroupModel selectedGroup = _dropdownGroupList.get(position); + if (selectedGroup.isPlaceholder() && Objects.equals(selectedGroup.getName(), getString(R.string.new_group))) { + Dialogs.TextInputListener onAddGroup = text -> { + String groupName = new String(text).trim(); + if (!groupName.isEmpty()) { + VaultGroup group = _vaultManager.getVault().findGroupByName(groupName); + if (group == null) { + group = new VaultGroup(groupName); + _vaultManager.getVault().addGroup(group); } - }); - _dropdownGroup.setText(_dropdownGroupList.get(prevPosition), false); - } else { - prevPosition = position; - } + + updateGroupDropdownList(); + setGroup(new VaultGroupModel(group)); + } + }; + + DialogInterface.OnCancelListener onCancel = dialogInterface -> { + VaultGroupModel previous = (VaultGroupModel) _dropdownGroup.getTag(); + _dropdownGroup.setText(previous.getName(), false); + }; + + Dialogs.showTextInputDialog(EditEntryActivity.this, R.string.set_group, R.string.group_name_hint, onAddGroup, onCancel); + } else { + setGroup(_dropdownGroupList.get(position)); } }); @@ -365,13 +381,9 @@ public class EditEntryActivity extends AegisActivity { _textPin.setHint(otpType.equals(MotpInfo.ID) ? R.string.motp_pin : R.string.yandex_pin); } - private void setGroup(String groupName) { - int pos = 0; - if (groupName != null) { - pos = _groups.contains(groupName) ? _groups.headSet(groupName).size() + 1 : 0; - } - - _dropdownGroup.setText(_dropdownGroupList.get(pos), false); + private void setGroup(VaultGroupModel group) { + _dropdownGroup.setText(group.getName(), false); + _dropdownGroup.setTag(group); } private void openAdvancedSettings() { @@ -395,11 +407,10 @@ public class EditEntryActivity extends AegisActivity { } private void updateGroupDropdownList() { - Resources res = getResources(); _dropdownGroupList.clear(); - _dropdownGroupList.add(res.getString(R.string.no_group)); - _dropdownGroupList.addAll(_groups); - _dropdownGroupList.add(res.getString(R.string.new_group)); + _dropdownGroupList.add(new VaultGroupModel(getString(R.string.new_group))); + _dropdownGroupList.addAll(_groups.stream().map(VaultGroupModel::new).collect(Collectors.toList())); + _dropdownGroupList.add(new VaultGroupModel(getString(R.string.no_group))); } private boolean hasUnsavedChanges(VaultEntry newEntry) { @@ -726,12 +737,13 @@ public class EditEntryActivity extends AegisActivity { entry.setName(_textName.getText().toString()); entry.setNote(_textNote.getText().toString()); - int groupPos = _dropdownGroupList.indexOf(_dropdownGroup.getText().toString()); - if (groupPos != 0) { - String group = _dropdownGroupList.get(groupPos); - entry.setGroup(group); + VaultGroupModel group = (VaultGroupModel) _dropdownGroup.getTag(); + if (group.isPlaceholder()) { + entry.setGroups(new HashSet<>()); } else { - entry.setGroup(null); + Set groups = new HashSet<>(); + groups.add(group.getUUID()); + entry.setGroups(groups); } if (_hasChangedIcon) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java index a894c1a5..f1815711 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/GroupManagerActivity.java @@ -14,16 +14,17 @@ import androidx.recyclerview.widget.RecyclerView; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.views.GroupAdapter; -import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Objects; +import java.util.Set; +import java.util.UUID; public class GroupManagerActivity extends AegisActivity implements GroupAdapter.Listener { private GroupAdapter _adapter; - private HashSet _removedGroups; + private HashSet _removedGroups; private RecyclerView _groupsView; private View _emptyStateView; private BackPressHandler _backPressHandler; @@ -43,11 +44,14 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter. _backPressHandler = new BackPressHandler(); getOnBackPressedDispatcher().addCallback(this, _backPressHandler); + _removedGroups = new HashSet<>(); if (savedInstanceState != null) { List groups = savedInstanceState.getStringArrayList("removedGroups"); - _removedGroups = new HashSet<>(Objects.requireNonNull(groups)); - } else { - _removedGroups = new HashSet<>(); + if (groups != null) { + for (String uuid : groups) { + _removedGroups.add(UUID.fromString(uuid)); + } + } } _adapter = new GroupAdapter(this); @@ -57,8 +61,10 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter. _groupsView.setAdapter(_adapter); _groupsView.setNestedScrollingEnabled(false); - for (String group : _vaultManager.getVault().getGroups()) { - _adapter.addGroup(group); + for (VaultGroup group : _vaultManager.getVault().getGroups()) { + if (!_removedGroups.contains(group.getUUID())) { + _adapter.addGroup(group); + } } _emptyStateView = findViewById(R.id.vEmptyList); @@ -68,16 +74,21 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter. @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - outState.putStringArrayList("removedGroups", new ArrayList<>(_removedGroups)); + ArrayList removed = new ArrayList<>(); + for (UUID uuid : _removedGroups) { + removed.add(uuid.toString()); + } + + outState.putStringArrayList("removedGroups", removed); } @Override - public void onRemoveGroup(String group) { + public void onRemoveGroup(VaultGroup group) { Dialogs.showSecureDialog(new AlertDialog.Builder(this) .setTitle(R.string.remove_group) .setMessage(R.string.remove_group_description) .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { - _removedGroups.add(group); + _removedGroups.add(group.getUUID()); _adapter.removeGroup(group); _backPressHandler.setEnabled(true); updateEmptyState(); @@ -86,12 +97,29 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter. .create()); } + public void onRemoveUnusedGroups() { + Dialogs.showSecureDialog(new AlertDialog.Builder(this) + .setTitle(R.string.remove_unused_groups) + .setMessage(R.string.remove_unused_groups_description) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + Set unusedGroups = new HashSet<>(_vaultManager.getVault().getGroups()); + unusedGroups.removeAll(_vaultManager.getVault().getUsedGroups()); + + for (VaultGroup group : unusedGroups) { + _removedGroups.add(group.getUUID()); + _adapter.removeGroup(group); + } + _backPressHandler.setEnabled(true); + updateEmptyState(); + }) + .setNegativeButton(android.R.string.no, null) + .create()); + } + private void saveAndFinish() { if (!_removedGroups.isEmpty()) { - for (VaultEntry entry : _vaultManager.getVault().getEntries()) { - if (_removedGroups.contains(entry.getGroup())) { - entry.setGroup(null); - } + for (UUID uuid : _removedGroups) { + _vaultManager.getVault().removeGroup(uuid); } saveAndBackupVault(); @@ -126,6 +154,9 @@ public class GroupManagerActivity extends AegisActivity implements GroupAdapter. case R.id.action_save: saveAndFinish(); break; + case R.id.action_delete_unused_groups: + onRemoveUnusedGroups(); + break; default: return super.onOptionsItemSelected(item); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java index 8e126189..0a992b1a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/ImportEntriesActivity.java @@ -25,6 +25,7 @@ import com.beemdevelopment.aegis.ui.tasks.RootShellTask; import com.beemdevelopment.aegis.ui.views.ImportEntriesAdapter; import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.beemdevelopment.aegis.vault.VaultRepository; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; @@ -37,6 +38,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.UUID; public class ImportEntriesActivity extends AegisActivity { @@ -45,6 +47,8 @@ public class ImportEntriesActivity extends AegisActivity { private ImportEntriesAdapter _adapter; private FabScrollHelper _fabScrollHelper; + private UUIDMap _importedGroups; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -205,6 +209,8 @@ public class ImportEntriesActivity extends AegisActivity { importEntries.add(importEntry); } + _importedGroups = result.getGroups(); + List errors = result.getErrors(); if (errors.size() > 0) { String message = getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size()); @@ -225,10 +231,43 @@ public class ImportEntriesActivity extends AegisActivity { private void saveAndFinish(boolean wipeEntries) { VaultRepository vault = _vaultManager.getVault(); if (wipeEntries) { - vault.wipeEntries(); + vault.wipeContents(); } + // Given the list of selected entries, collect the UUID's of all groups + // that we're actually going to import List selectedEntries = _adapter.getCheckedEntries(); + List selectedGroupUuids = new ArrayList<>(); + for (ImportEntry entry : selectedEntries) { + selectedGroupUuids.addAll(entry.getEntry().getGroups()); + } + + // Add all of the new groups to the vault. If a group with the same name already + // exists in the vault, rewrite all entries in that group to reference the existing group. + for (VaultGroup importedGroup : _importedGroups) { + if (!selectedGroupUuids.contains(importedGroup.getUUID())) { + continue; + } + + VaultGroup existingGroup = vault.findGroupByUUID(importedGroup.getUUID()); + if (existingGroup != null) { + continue; + } + + existingGroup = vault.findGroupByName(importedGroup.getName()); + if (existingGroup == null) { + vault.addGroup(importedGroup); + } else { + for (ImportEntry entry : selectedEntries) { + Set entryGroups = entry.getEntry().getGroups(); + if (entryGroups.contains(importedGroup.getUUID())) { + entryGroups.remove(importedGroup.getUUID()); + entryGroups.add(existingGroup.getUUID()); + } + } + } + } + for (ImportEntry selectedEntry : selectedEntries) { VaultEntry entry = selectedEntry.getEntry(); diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java index a43b1643..2a43b634 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -63,7 +63,7 @@ import java.util.Date; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.TreeSet; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -632,7 +632,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene startAuthActivity(false); } else if (_loaded) { // update the list of groups in the entry list view so that the chip gets updated - _entryListView.setGroups(_vaultManager.getVault().getGroups()); + _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); // update the usage counts in case they are edited outside of the EntryListView _entryListView.setUsageCounts(_prefs.getUsageCounts()); @@ -670,7 +670,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene updateLockIcon(); if (_loaded) { - _entryListView.setGroups(_vaultManager.getVault().getGroups()); + _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); updateSortCategoryMenu(); } @@ -980,7 +980,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene public void onListChange() { _fabScrollHelper.setVisible(true); } @Override - public void onSaveGroupFilter(List groupFilter) { + public void onSaveGroupFilter(Set groupFilter) { _prefs.setGroupFilter(groupFilter); } @@ -1128,17 +1128,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene case R.id.action_delete: Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { deleteEntries(_selectedEntries); - - for (VaultEntry entry : _selectedEntries) { - if (entry.getGroup() != null) { - TreeSet groups = _vaultManager.getVault().getGroups(); - if (!groups.contains(entry.getGroup())) { - _entryListView.setGroups(groups); - break; - } - } - } - + _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); mode.finish(); }); return true; diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java b/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java index d61998fd..06249985 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/components/DropdownCheckBoxes.java @@ -18,19 +18,19 @@ import androidx.appcompat.widget.AppCompatAutoCompleteTextView; import com.beemdevelopment.aegis.R; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.TreeSet; import java.util.stream.Collectors; -public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { +public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { private @PluralsRes int _selectedCountPlural = R.plurals.dropdown_checkboxes_default_count; private boolean _allowFiltering = false; - private final List _items = new ArrayList<>(); - private List _visibleItems = new ArrayList<>(); - private final Set _checkedItems = new TreeSet<>(); + private final List _items = new ArrayList<>(); + private List _visibleItems = new ArrayList<>(); + private final Set _checkedItems = new HashSet<>(); private CheckboxAdapter _adapter; @@ -70,7 +70,15 @@ public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { } } - public void addItems(List items, boolean startChecked) { + /** + * Add parameterized items to be displayed as a checkbox in the dropdown view + * the label for the checkbox is determined by the toString() method of the items + * you add. + * + * @param items a list of the items you want to show in the dropdown + * @param startChecked whether the checkbox should be checked initially + */ + public void addItems(List items, boolean startChecked) { _items.addAll(items); _visibleItems.addAll(items); @@ -97,7 +105,7 @@ public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { _selectedCountPlural = resId; } - public Set getCheckedItems() { + public Set getCheckedItems() { return _checkedItems; } @@ -109,7 +117,7 @@ public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { } @Override - public String getItem(int i) { + public T getItem(int i) { return _visibleItems.get(i); } @@ -124,19 +132,18 @@ public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { convertView = LayoutInflater.from(getContext()).inflate(R.layout.dropdown_checkbox, viewGroup, false); } - String item = _visibleItems.get(i); + T item = _visibleItems.get(i); CheckBox checkBox = convertView.findViewById(R.id.checkbox_in_dropdown); - checkBox.setText(item); + checkBox.setText(item.toString()); + checkBox.setTag(item); checkBox.setChecked(_checkedItems.contains(item)); checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { - String label = buttonView.getText().toString(); - if (isChecked) { - _checkedItems.add(label); + _checkedItems.add((T) buttonView.getTag()); } else { - _checkedItems.remove(label); + _checkedItems.remove((T) buttonView.getTag()); } updateCheckedItemsCountText(); @@ -153,9 +160,9 @@ public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { FilterResults results = new FilterResults(); results.values = (query == null || query.toString().isEmpty()) ? _items - : _items.stream().filter(str -> { + : _items.stream().filter(item -> { String q = query.toString().toLowerCase(); - String strLower = str.toLowerCase(); + String strLower = item.toString().toLowerCase(); return strLower.contains(q); }) @@ -166,7 +173,7 @@ public class DropdownCheckBoxes extends AppCompatAutoCompleteTextView { @Override protected void publishResults(CharSequence charSequence, FilterResults filterResults) { - _visibleItems = (List) filterResults.values; + _visibleItems = (List) filterResults.values; notifyDataSetChanged(); } }; diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java index 3b9d8ab0..1dd7c873 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/dialogs/Dialogs.java @@ -25,6 +25,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.activity.ComponentActivity; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; @@ -242,8 +243,8 @@ public class Dialogs { showTextInputDialog(context, titleId, 0, hintId, listener, null, isSecret); } - public static void showTextInputDialog(Context context, @StringRes int titleId, @StringRes int hintId, TextInputListener listener) { - showTextInputDialog(context, titleId, hintId, listener, false); + public static void showTextInputDialog(Context context, @StringRes int titleId, @StringRes int hintId, TextInputListener listener, @Nullable DialogInterface.OnCancelListener onCancel) { + showTextInputDialog(context, titleId, 0, hintId, listener, onCancel, false); } public static void showPasswordInputDialog(Context context, TextInputListener listener) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/ImportExportPreferencesFragment.java b/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/ImportExportPreferencesFragment.java index 7e15f04f..43c5da61 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/ImportExportPreferencesFragment.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/fragments/preferences/ImportExportPreferencesFragment.java @@ -31,12 +31,14 @@ import com.beemdevelopment.aegis.ui.ImportEntriesActivity; import com.beemdevelopment.aegis.ui.TransferEntriesActivity; import com.beemdevelopment.aegis.ui.components.DropdownCheckBoxes; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; +import com.beemdevelopment.aegis.ui.models.VaultGroupModel; import com.beemdevelopment.aegis.ui.tasks.ExportTask; import com.beemdevelopment.aegis.ui.tasks.ImportFileTask; import com.beemdevelopment.aegis.vault.Vault; import com.beemdevelopment.aegis.vault.VaultBackupManager; import com.beemdevelopment.aegis.vault.VaultEntry; import com.beemdevelopment.aegis.vault.VaultFileCredentials; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.beemdevelopment.aegis.vault.VaultRepository; import com.beemdevelopment.aegis.vault.VaultRepositoryException; import com.beemdevelopment.aegis.vault.slots.PasswordSlot; @@ -48,12 +50,14 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Random; import java.util.Set; -import java.util.TreeSet; +import java.util.UUID; +import java.util.stream.Collectors; import javax.crypto.Cipher; @@ -164,7 +168,7 @@ public class ImportExportPreferencesFragment extends PreferencesFragment { CheckBox checkBoxAccept = view.findViewById(R.id.checkbox_accept); CheckBox checkBoxExportAllGroups = view.findViewById(R.id.export_selected_groups); TextInputLayout groupsSelectionLayout = view.findViewById(R.id.group_selection_layout); - DropdownCheckBoxes groupsSelection = view.findViewById(R.id.group_selection_dropdown); + DropdownCheckBoxes groupsSelection = view.findViewById(R.id.group_selection_dropdown); TextView passwordInfoText = view.findViewById(R.id.text_separate_password); passwordInfoText.setVisibility(checkBoxEncrypt.isChecked() && isBackupPasswordSet ? View.VISIBLE : View.GONE); AutoCompleteTextView dropdown = view.findViewById(R.id.dropdown_export_format); @@ -177,13 +181,13 @@ public class ImportExportPreferencesFragment extends PreferencesFragment { passwordInfoText.setVisibility(checkBoxEncrypt.isChecked() && isBackupPasswordSet ? View.VISIBLE : View.GONE); }); - TreeSet groups = _vaultManager.getVault().getGroups(); + Collection groups = _vaultManager.getVault().getUsedGroups(); if (groups.size() > 0) { checkBoxExportAllGroups.setVisibility(View.VISIBLE); - ArrayList groupsArray = new ArrayList<>(); - groupsArray.add(getString(R.string.no_group)); - groupsArray.addAll(groups); + ArrayList groupsArray = new ArrayList<>(); + groupsArray.add(new VaultGroupModel(getString(R.string.no_group))); + groupsArray.addAll(groups.stream().map(VaultGroupModel::new).collect(Collectors.toList())); groupsSelection.setCheckedItemsCountTextRes(R.plurals.export_groups_selected_count); groupsSelection.addItems(groupsArray, false); @@ -319,17 +323,19 @@ public class ImportExportPreferencesFragment extends PreferencesFragment { Dialogs.showSecureDialog(dialog); } - private Vault.EntryFilter getVaultEntryFilter(DropdownCheckBoxes dropdownCheckBoxes) { - Set groups = new HashSet<>(); - for (String group: dropdownCheckBoxes.getCheckedItems()) { - if (group.equals(getString(R.string.no_group))) { - groups.add(null); - } else { - groups.add(group); - } + private Vault.EntryFilter getVaultEntryFilter(DropdownCheckBoxes dropdownCheckBoxes) { + Set groups = new HashSet<>(); + for (VaultGroupModel group : dropdownCheckBoxes.getCheckedItems()) { + groups.add(group.getUUID()); } - return groups.isEmpty() ? null : entry -> groups.contains(entry.getGroup()); + return groups.isEmpty() ? null : entry -> { + if (entry.getGroups().isEmpty()) { + return groups.contains(null); + } else { + return entry.getGroups().stream().anyMatch(groups::contains); + } + }; } private void startGoogleAuthenticatorStyleExport() { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/models/VaultGroupModel.java b/app/src/main/java/com/beemdevelopment/aegis/ui/models/VaultGroupModel.java new file mode 100644 index 00000000..2cdcadca --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/models/VaultGroupModel.java @@ -0,0 +1,45 @@ +package com.beemdevelopment.aegis.ui.models; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.beemdevelopment.aegis.vault.VaultGroup; +import java.io.Serializable; +import java.util.UUID; + +public class VaultGroupModel implements Serializable { + private final VaultGroup _group; + private final String _placeholderName; + + public VaultGroupModel(VaultGroup group) { + _group = group; + _placeholderName = null; + } + + public VaultGroupModel(String placeholderName) { + _group = null; + _placeholderName = placeholderName; + } + + public VaultGroup getGroup() { + return _group; + } + + public String getName() { + return _group != null ? _group.getName() : _placeholderName; + } + + public boolean isPlaceholder() { + return _group == null; + } + + @Nullable + public UUID getUUID() { + return _group == null ? null : _group.getUUID(); + } + + @NonNull + @Override + public String toString() { + return getName(); + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryAdapter.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryAdapter.java index 3d9abfed..5697a295 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryAdapter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/EntryAdapter.java @@ -39,6 +39,8 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.TreeSet; import java.util.UUID; @@ -59,7 +61,7 @@ public class EntryAdapter extends RecyclerView.Adapter private boolean _tapToReveal; private int _tapToRevealTime; private CopyBehavior _copyBehavior; - private List _groupFilter; + private Set _groupFilter; private SortCategory _sortCategory; private ViewMode _viewMode; private String _searchFilter; @@ -76,7 +78,7 @@ public class EntryAdapter extends RecyclerView.Adapter _entries = new ArrayList<>(); _shownEntries = new ArrayList<>(); _selectedEntries = new ArrayList<>(); - _groupFilter = new ArrayList<>(); + _groupFilter = new TreeSet<>(); _holders = new ArrayList<>(); _dimHandler = new Handler(); _doubleTapHandler = new Handler(); @@ -246,12 +248,15 @@ public class EntryAdapter extends RecyclerView.Adapter } private boolean isEntryFiltered(VaultEntry entry) { - String group = entry.getGroup(); + Set groups = entry.getGroups(); String issuer = entry.getIssuer().toLowerCase(); String name = entry.getName().toLowerCase(); if (!_groupFilter.isEmpty()) { - if (!_groupFilter.contains(group)) { + if (groups.isEmpty() && !_groupFilter.contains(null)) { + return true; + } + if (!groups.isEmpty() && _groupFilter.stream().filter(Objects::nonNull).noneMatch(groups::contains)) { return true; } } @@ -274,7 +279,7 @@ public class EntryAdapter extends RecyclerView.Adapter } } - public void setGroupFilter(@NonNull List groups) { + public void setGroupFilter(@NonNull Set groups) { if (_groupFilter.equals(groups)) { return; } @@ -352,10 +357,6 @@ public class EntryAdapter extends RecyclerView.Adapter return (int) _shownEntries.stream().filter(VaultEntry::isFavorite).count(); } - public void setGroups(TreeSet groups) { - _view.setGroups(groups); - } - @Override public void onItemDismiss(int position) { 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 4a9183a9..36edc0b6 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 @@ -37,7 +37,10 @@ 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.ui.models.VaultGroupModel; +import com.beemdevelopment.aegis.util.UUIDMap; import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultGroup; import com.bumptech.glide.Glide; import com.bumptech.glide.ListPreloader; import com.bumptech.glide.RequestBuilder; @@ -54,7 +57,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.TreeSet; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -71,11 +74,11 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { private TotpProgressBar _progressBar; private boolean _showProgress; private ViewMode _viewMode; - private TreeSet _groups; + private Collection _groups; private LinearLayout _emptyStateView; private Chip _groupChip; - private List _groupFilter; - private List _prefGroupFilter; + private Set _groupFilter; + private Set _prefGroupFilter; private UiRefresher _refresher; @@ -168,7 +171,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { super.onDestroyView(); } - public void setGroupFilter(List groups, boolean animate) { + public void setGroupFilter(Set groups, boolean animate) { _groupFilter = groups; _adapter.setGroupFilter(groups); _touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed()); @@ -334,7 +337,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { } } - public void setPrefGroupFilter(List groupFilter) { + public void setPrefGroupFilter(Set groupFilter) { _prefGroupFilter = groupFilter; } @@ -465,17 +468,17 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { _recyclerView.scheduleLayoutAnimation(); } - private void addChipTo(ChipGroup chipGroup, String group) { + private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) { Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_material, null, false); - chip.setText(group == null ? getString(R.string.no_group) : group); + chip.setText(group.getName()); chip.setCheckable(true); - chip.setChecked(_groupFilter != null && _groupFilter.contains(group)); + chip.setChecked(_groupFilter != null && _groupFilter.contains(group.getUUID())); chip.setCheckedIconVisible(true); chip.setOnCheckedChangeListener((group1, checkedId) -> { - List groupFilter = getGroupFilter(chipGroup); + Set groupFilter = getGroupFilter(chipGroup); setGroupFilter(groupFilter, true); }); - chip.setTag(group == null ? new Object() : null); + chip.setTag(group); chipGroup.addView(chip); } @@ -489,7 +492,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { Button saveButton = view.findViewById(R.id.btnSave); clearButton.setOnClickListener(v -> { chipGroup.clearCheck(); - List groupFilter = Collections.emptyList(); + Set groupFilter = Collections.emptySet(); if (_listener != null) { _listener.onSaveGroupFilter(groupFilter); } @@ -498,7 +501,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { }); saveButton.setOnClickListener(v -> { - List groupFilter = getGroupFilter(chipGroup); + Set groupFilter = getGroupFilter(chipGroup); if (_listener != null) { _listener.onSaveGroupFilter(groupFilter); } @@ -509,25 +512,23 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { _groupChip.setOnClickListener(v -> { chipGroup.removeAllViews(); - for (String group : _groups) { - addChipTo(chipGroup, group); + for (VaultGroup group : _groups) { + addChipTo(chipGroup, new VaultGroupModel(group)); } - addChipTo(chipGroup, null); + addChipTo(chipGroup, new VaultGroupModel(getString(R.string.no_group))); Dialogs.showSecureDialog(dialog); }); } - private static List getGroupFilter(ChipGroup chipGroup) { + private static Set getGroupFilter(ChipGroup chipGroup) { return chipGroup.getCheckedChipIds().stream() .map(i -> { Chip chip = chipGroup.findViewById(i); - if (chip.getTag() != null) { - return null; - } - return chip. getText().toString(); + VaultGroupModel group = (VaultGroupModel) chip.getTag(); + return group.getUUID(); }) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); } private void updateGroupChip() { @@ -543,29 +544,31 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { updateDividerDecoration(); } - public void setGroups(TreeSet groups) { + public void setGroups(Collection groups) { _groups = groups; _groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE); updateDividerDecoration(); if (_prefGroupFilter != null) { - List groupFilter = cleanGroupFilter(_prefGroupFilter); + Set groupFilter = cleanGroupFilter(_prefGroupFilter); _prefGroupFilter = null; if (!groupFilter.isEmpty()) { setGroupFilter(groupFilter, false); } } else if (_groupFilter != null) { - List groupFilter = cleanGroupFilter(_groupFilter); + Set groupFilter = cleanGroupFilter(_groupFilter); if (!_groupFilter.equals(groupFilter)) { setGroupFilter(groupFilter, true); } } } - private List cleanGroupFilter(List groupFilter) { - return groupFilter.stream() - .filter(g -> g == null || _groups.contains(g)) - .collect(Collectors.toList()); + private Set cleanGroupFilter(Set groupFilter) { + Set groupUuids = _groups.stream().map(UUIDMap.Value::getUUID).collect(Collectors.toSet()); + + return groupFilter.stream() + .filter(g -> g == null || groupUuids.contains(g)) + .collect(Collectors.toSet()); } private void updateDividerDecoration() { @@ -616,7 +619,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener { void onSelect(VaultEntry entry); void onDeselect(VaultEntry entry); void onListChange(); - void onSaveGroupFilter(List groupFilter); + void onSaveGroupFilter(Set groupFilter); void onEntryListTouch(); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupAdapter.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupAdapter.java index 3d04e3a9..60f6b4c3 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupAdapter.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupAdapter.java @@ -7,19 +7,20 @@ import android.view.ViewGroup; import androidx.recyclerview.widget.RecyclerView; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.vault.VaultGroup; import java.util.ArrayList; public class GroupAdapter extends RecyclerView.Adapter { private GroupAdapter.Listener _listener; - private ArrayList _groups; + private ArrayList _groups; public GroupAdapter(GroupAdapter.Listener listener) { _listener = listener; _groups = new ArrayList<>(); } - public void addGroup(String group) { + public void addGroup(VaultGroup group) { _groups.add(group); int position = getItemCount() - 1; @@ -30,7 +31,7 @@ public class GroupAdapter extends RecyclerView.Adapter { } } - public void removeGroup(String group) { + public void removeGroup(VaultGroup group) { int position = _groups.indexOf(group); _groups.remove(position); notifyItemRemoved(position); @@ -57,6 +58,6 @@ public class GroupAdapter extends RecyclerView.Adapter { } public interface Listener { - void onRemoveGroup(String group); + void onRemoveGroup(VaultGroup group); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupHolder.java b/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupHolder.java index ed490500..5068d9bd 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupHolder.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/views/GroupHolder.java @@ -7,6 +7,7 @@ import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.vault.VaultGroup; public class GroupHolder extends RecyclerView.ViewHolder { private TextView _slotName; @@ -18,8 +19,8 @@ public class GroupHolder extends RecyclerView.ViewHolder { _buttonDelete = view.findViewById(R.id.button_delete); } - public void setData(String groupName) { - _slotName.setText(groupName); + public void setData(VaultGroup group) { + _slotName.setText(group.getName()); } public void setOnDeleteClickListener(View.OnClickListener listener) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/util/UUIDMap.java b/app/src/main/java/com/beemdevelopment/aegis/util/UUIDMap.java index a1aea132..a8294d81 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/util/UUIDMap.java +++ b/app/src/main/java/com/beemdevelopment/aegis/util/UUIDMap.java @@ -9,6 +9,7 @@ import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Objects; import java.util.UUID; /** @@ -99,7 +100,14 @@ public class UUIDMap implements Iterable, Serializa * Reports whether the internal map contains a value with the UUID of the given value. */ public boolean has(T value) { - return _map.containsKey(value.getUUID()); + return has(value.getUUID()); + } + + /** + * Reports whether the internal map contains a value with the given UUID. + */ + public boolean has(UUID uuid) { + return _map.containsKey(uuid); } /** @@ -161,7 +169,7 @@ public class UUIDMap implements Iterable, Serializa return false; } - return getUUID().equals(((Value) o).getUUID()); + return Objects.equals(getUUID(), ((Value) o).getUUID()); } } } 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 74ea038c..de7e434d 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/Vault.java @@ -8,9 +8,13 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import java.util.Optional; +import java.util.UUID; + public class Vault { - private static final int VERSION = 2; - private UUIDMap _entries = new UUIDMap<>(); + private static final int VERSION = 3; + private final UUIDMap _entries = new UUIDMap<>(); + private final UUIDMap _groups = new UUIDMap<>(); public JSONObject toJson() { return toJson(null); @@ -18,16 +22,24 @@ public class Vault { public JSONObject toJson(@Nullable EntryFilter filter) { try { - JSONArray array = new JSONArray(); + JSONArray entriesArray = new JSONArray(); for (VaultEntry e : _entries) { if (filter == null || filter.includeEntry(e)) { - array.put(e.toJson()); + entriesArray.put(e.toJson()); } } + // Always include all groups, even if they're not assigned to any entry (before or after the entry filter) + JSONArray groupsArray = new JSONArray(); + for (VaultGroup group : _groups) { + groupsArray.put(group.toJson()); + } + JSONObject obj = new JSONObject(); obj.put("version", VERSION); - obj.put("entries", array); + obj.put("entries", entriesArray); + obj.put("groups", groupsArray); + return obj; } catch (JSONException e) { throw new RuntimeException(e); @@ -37,6 +49,7 @@ public class Vault { public static Vault fromJson(JSONObject obj) throws VaultException { Vault vault = new Vault(); UUIDMap entries = vault.getEntries(); + UUIDMap groups = vault.getGroups(); try { int ver = obj.getInt("version"); @@ -44,9 +57,28 @@ public class Vault { throw new VaultException("Unsupported version"); } + if (obj.has("groups")) { + JSONArray groupsArray = obj.getJSONArray("groups"); + for (int i = 0; i < groupsArray.length(); i++) { + VaultGroup group = VaultGroup.fromJson(groupsArray.getJSONObject(i)); + if (!groups.has(group)) { + groups.add(group); + } + } + } + JSONArray array = obj.getJSONArray("entries"); for (int i = 0; i < array.length(); i++) { VaultEntry entry = VaultEntry.fromJson(array.getJSONObject(i)); + vault.migrateOldGroup(entry); + + // check the vault has a group corresponding to each one the entry claims to be in + for (UUID groupUuid: entry.getGroups()) { + if (!groups.has(groupUuid)) { + entry.removeGroup(groupUuid); + } + } + entries.add(entry); } } catch (VaultEntryException | JSONException e) { @@ -56,10 +88,33 @@ public class Vault { return vault; } + public void migrateOldGroup(VaultEntry entry) { + if (entry.getOldGroup() != null) { + Optional optGroup = getGroups().getValues() + .stream() + .filter(g -> g.getName().equals(entry.getOldGroup())) + .findFirst(); + + if (optGroup.isPresent()) { + entry.addGroup(optGroup.get().getUUID()); + } else { + VaultGroup group = new VaultGroup(entry.getOldGroup()); + getGroups().add(group); + entry.addGroup(group.getUUID()); + } + + entry.setOldGroup(null); + } + } + public UUIDMap getEntries() { return _entries; } + public UUIDMap getGroups() { + return _groups; + } + public interface EntryFilter { boolean includeEntry(VaultEntry entry); } 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 9ad2d70b..46a56b95 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultEntry.java @@ -10,23 +10,26 @@ import com.beemdevelopment.aegis.otp.TotpInfo; import com.beemdevelopment.aegis.util.JsonUtils; import com.beemdevelopment.aegis.util.UUIDMap; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.Arrays; -import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; import java.util.UUID; public class VaultEntry extends UUIDMap.Value { private String _name = ""; private String _issuer = ""; - private String _group; private OtpInfo _info; private byte[] _icon; private IconType _iconType = IconType.INVALID; private boolean _isFavorite; private int _usageCount; private String _note = ""; + private String _oldGroup; + private Set _groups = new TreeSet<>(); private VaultEntry(UUID uuid, OtpInfo info) { super(uuid); @@ -44,13 +47,6 @@ public class VaultEntry extends UUIDMap.Value { setIssuer(issuer); } - public VaultEntry(OtpInfo info, String name, String issuer, String group) { - this(info); - setName(name); - setIssuer(issuer); - setGroup(group); - } - public VaultEntry(GoogleAuthInfo info) { this(info.getOtpInfo(), info.getAccountName(), info.getIssuer()); } @@ -63,12 +59,18 @@ public class VaultEntry extends UUIDMap.Value { obj.put("uuid", getUUID().toString()); obj.put("name", _name); obj.put("issuer", _issuer); - obj.put("group", _group); obj.put("note", _note); obj.put("favorite", _isFavorite); obj.put("icon", _icon == null ? JSONObject.NULL : Base64.encode(_icon)); obj.put("icon_mime", _icon == null ? null : _iconType.toMimeType()); obj.put("info", _info.toJson()); + + JSONArray groupUuids = new JSONArray(); + for (UUID uuid : _groups) { + groupUuids.put(uuid.toString()); + } + obj.put("groups", groupUuids); + } catch (JSONException e) { throw new RuntimeException(e); } @@ -90,10 +92,21 @@ public class VaultEntry extends UUIDMap.Value { VaultEntry entry = new VaultEntry(uuid, info); entry.setName(obj.getString("name")); entry.setIssuer(obj.getString("issuer")); - entry.setGroup(obj.optString("group", null)); entry.setNote(obj.optString("note", "")); entry.setIsFavorite(obj.optBoolean("favorite", false)); + // If the entry contains a list of group UUID's, assume conversion from the + // old group system has already taken place and ignore the old group field. + if (obj.has("groups")) { + JSONArray groups = obj.getJSONArray("groups"); + for (int i = 0; i < groups.length(); i++) { + String groupUuid = groups.getString(i); + entry.addGroup(UUID.fromString(groupUuid)); + } + } else if (obj.has("group")) { + entry.setOldGroup(JsonUtils.optString(obj, "group")); + } + Object icon = obj.get("icon"); if (icon != JSONObject.NULL) { String mime = JsonUtils.optString(obj, "icon_mime"); @@ -121,8 +134,8 @@ public class VaultEntry extends UUIDMap.Value { return _issuer; } - public String getGroup() { - return _group; + public Set getGroups() { + return _groups; } public byte[] getIcon() { @@ -153,8 +166,22 @@ public class VaultEntry extends UUIDMap.Value { _issuer = issuer; } - public void setGroup(String group) { - _group = group; + public void addGroup(UUID group) { + if (group == null) { + throw new AssertionError("Attempt to add null group to entry's group list"); + } + _groups.add(group); + } + + public void removeGroup(UUID group) { + _groups.remove(group); + } + + public void setGroups(Set groups) { + if (groups.contains(null)) { + throw new AssertionError("Attempt to add null group to entry's group list"); + } + _groups = groups; } public void setInfo(OtpInfo info) { @@ -176,6 +203,14 @@ public class VaultEntry extends UUIDMap.Value { public void setIsFavorite(boolean isFavorite) { _isFavorite = isFavorite; } + void setOldGroup(String oldGroup) { + _oldGroup = oldGroup; + } + + String getOldGroup() { + return _oldGroup; + } + @Override public boolean equals(Object o) { if (!(o instanceof VaultEntry)) { @@ -194,12 +229,12 @@ public class VaultEntry extends UUIDMap.Value { public boolean equivalates(VaultEntry entry) { return getName().equals(entry.getName()) && getIssuer().equals(entry.getIssuer()) - && Objects.equals(getGroup(), entry.getGroup()) && getInfo().equals(entry.getInfo()) && Arrays.equals(getIcon(), entry.getIcon()) && getIconType().equals(entry.getIconType()) && getNote().equals(entry.getNote()) - && isFavorite() == entry.isFavorite(); + && isFavorite() == entry.isFavorite() + && getGroups().equals(entry.getGroups()); } /** diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultGroup.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultGroup.java new file mode 100644 index 00000000..263b4eb7 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultGroup.java @@ -0,0 +1,69 @@ +package com.beemdevelopment.aegis.vault; + +import com.beemdevelopment.aegis.util.UUIDMap; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.UUID; + +public class VaultGroup extends UUIDMap.Value { + private String _name; + + private VaultGroup(UUID uuid, String name) { + super(uuid); + _name = name; + } + + public VaultGroup(String name) { + super(); + _name = name; + } + + public JSONObject toJson() { + JSONObject obj = new JSONObject(); + + try { + obj.put("uuid", getUUID().toString()); + obj.put("name", _name); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + return obj; + } + + public static VaultGroup fromJson(JSONObject obj) throws VaultEntryException { + try { + UUID uuid = UUID.fromString(obj.getString("uuid")); + String groupName = obj.getString("name"); + + return new VaultGroup(uuid, groupName); + } catch (JSONException e) { + throw new VaultEntryException(e); + } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof VaultGroup)) { + return false; + } + + VaultGroup entry = (VaultGroup) o; + return super.equals(entry) && getName().equals(entry.getName()); + } + + public String getName() { + return _name; + } + + public void setName(String name) { + _name = name; + } + + @Override + public String toString() { + return _name; + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java index 94571e55..4e74023a 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java @@ -20,9 +20,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; -import java.text.Collator; import java.util.Collection; -import java.util.TreeSet; +import java.util.HashSet; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -220,6 +220,8 @@ public class VaultRepository { } public void addEntry(VaultEntry entry) { + // Entries added by importing a file may contain an old group that needs to be migrated + _vault.migrateOldGroup(entry); _vault.getEntries().add(entry); } @@ -231,8 +233,12 @@ public class VaultRepository { return _vault.getEntries().remove(entry); } - public void wipeEntries() { + /** + * Wipes all entries and groups from the vault. + */ + public void wipeContents() { _vault.getEntries().wipe(); + _vault.getGroups().wipe(); } public VaultEntry replaceEntry(VaultEntry entry) { @@ -240,8 +246,8 @@ public class VaultRepository { } /** - * Moves entry1 to the position of entry2. - */ + * Moves entry1 to the position of entry2. + */ public void moveEntry(VaultEntry entry1, VaultEntry entry2) { _vault.getEntries().move(entry1, entry2); } @@ -254,15 +260,54 @@ public class VaultRepository { return _vault.getEntries().getValues(); } - public TreeSet getGroups() { - TreeSet groups = new TreeSet<>(Collator.getInstance()); + public void addGroup(VaultGroup group) { + _vault.getGroups().add(group); + } + + public VaultGroup getGroupByUUID(UUID uuid) { + return _vault.getGroups().getByUUID(uuid); + } + + @Nullable + public VaultGroup findGroupByUUID(UUID uuid) { + return _vault.getGroups().has(uuid) ? _vault.getGroups().getByUUID(uuid) : null; + } + + @Nullable + public VaultGroup findGroupByName(String name) { + return _vault.getGroups().getValues() + .stream() + .filter(g -> g.getName().equals(name)) + .findFirst() + .orElse(null); + } + + public void removeGroup(UUID groupUuid) { + VaultGroup group = _vault.getGroups().getByUUID(groupUuid); + removeGroup(group); + } + + public void removeGroup(VaultGroup group) { for (VaultEntry entry : getEntries()) { - String group = entry.getGroup(); - if (group != null) { - groups.add(group); - } + entry.removeGroup(group.getUUID()); } - return groups; + + _vault.getGroups().remove(group); + } + + public Collection getGroups() { + return _vault.getGroups().getValues(); + } + + public Collection getUsedGroups() { + Set usedGroups = new HashSet<>(); + for (VaultEntry entry : getEntries()) { + usedGroups.addAll(entry.getGroups()); + } + + return getGroups().stream() + .filter(vg -> usedGroups.contains(vg.getUUID())) + .collect(Collectors.toList()); } public VaultFileCredentials getCredentials() { diff --git a/app/src/main/res/menu/menu_groups.xml b/app/src/main/res/menu/menu_groups.xml index 5e614e22..d0e52da2 100644 --- a/app/src/main/res/menu/menu_groups.xml +++ b/app/src/main/res/menu/menu_groups.xml @@ -3,6 +3,10 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" tools:context="com.beemdevelopment.aegis.ui.GroupManagerActivity"> + Unlocking the vault Remove group Are you sure you want to remove this group? Entries in this group will automatically switch to \'No group\'. + Delete unused groups + Are you sure you want to delete all groups that are not assigned to an entry? Remove icon pack Are you sure you want to remove this icon pack? Entries that use icons from this pack will not be affected. Details diff --git a/app/src/test/java/com/beemdevelopment/aegis/vault/VaultTest.java b/app/src/test/java/com/beemdevelopment/aegis/vault/VaultTest.java new file mode 100644 index 00000000..30f875c9 --- /dev/null +++ b/app/src/test/java/com/beemdevelopment/aegis/vault/VaultTest.java @@ -0,0 +1,71 @@ +package com.beemdevelopment.aegis.vault; + +import static org.junit.Assert.assertEquals; + +import com.beemdevelopment.aegis.util.IOUtils; + +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.stream.Collectors; + +public class VaultTest { + @Test + public void testGroupConversion() + throws IOException, VaultFileException, VaultException { + Vault vault; + try (InputStream inStream = getClass().getResourceAsStream("aegis_plain_grouped_v2.json")) { + byte[] bytes = IOUtils.readAll(inStream); + VaultFile file = VaultFile.fromBytes(bytes); + vault = Vault.fromJson(file.getContent()); + } + + checkGroups(vault); + + // After saving to and loading from the new format, the same checks should still pass + vault = Vault.fromJson(vault.toJson()); + checkGroups(vault); + } + + private static void checkGroups(Vault vault) { + // No old groups should be present anymore + assertEquals(0, vault.getEntries().getValues().stream() + .filter(e -> e.getOldGroup() != null) + .count()); + + // New groups should have been created, and groups with the same name + // should have been merged into a single group + assertEquals(2, vault.getGroups().getValues().size()); + + // Only one group with name group1 + List foundGroups = vault.getGroups().getValues().stream() + .filter(g -> g.getName().equals("group1")) + .collect(Collectors.toList()); + assertEquals(1, foundGroups.size()); + VaultGroup group1 = foundGroups.get(0); + + // Only one group with name group2 + foundGroups = vault.getGroups().getValues().stream() + .filter(g -> g.getName().equals("group2")) + .collect(Collectors.toList()); + assertEquals(1, foundGroups.size()); + VaultGroup group2 = foundGroups.get(0); + + // Two entries in group1 + assertEquals(2, vault.getEntries().getValues().stream() + .filter(e -> e.getGroups().contains(group1.getUUID())) + .count()); + + // One entry in group2 + assertEquals(1, vault.getEntries().getValues().stream() + .filter(e -> e.getGroups().contains(group2.getUUID())) + .count()); + + // Rest of the entries in no groups + assertEquals(vault.getEntries().getValues().size() - 3, vault.getEntries().getValues().stream() + .filter(e -> e.getGroups().isEmpty()) + .count()); + } +} diff --git a/app/src/test/resources/com/beemdevelopment/aegis/vault/aegis_plain_grouped_v2.json b/app/src/test/resources/com/beemdevelopment/aegis/vault/aegis_plain_grouped_v2.json new file mode 100644 index 00000000..4e601ed8 --- /dev/null +++ b/app/src/test/resources/com/beemdevelopment/aegis/vault/aegis_plain_grouped_v2.json @@ -0,0 +1,117 @@ +{ + "version": 1, + "header": { + "slots": null, + "params": null + }, + "db": { + "version": 2, + "entries": [ + { + "type": "totp", + "uuid": "3ae6f1ad-2e65-4ed2-a953-1ec0dff2386d", + "name": "Mason", + "issuer": "Deno", + "icon": null, + "icon_mime": null, + "group": "group1", + "info": { + "secret": "4SJHB4GSD43FZBAI7C2HLRJGPQ", + "algo": "SHA1", + "digits": 6, + "period": 30 + } + }, + { + "type": "totp", + "uuid": "84b55971-a3d2-4173-a5bb-0aea113dbc17", + "name": "James", + "issuer": "SPDX", + "icon": null, + "icon_mime": null, + "group": null, + "info": { + "secret": "5OM4WOOGPLQEF6UGN3CPEOOLWU", + "algo": "SHA256", + "digits": 7, + "period": 20 + } + }, + { + "type": "totp", + "uuid": "3deaff2e-f181-4837-80e1-fdf0c54e9363", + "name": "Elijah", + "issuer": "Airbnb", + "icon": null, + "icon_mime": null, + "group": "group1", + "info": { + "secret": "7ELGJSGXNCCTV3O6LKJWYFV2RA", + "algo": "SHA512", + "digits": 8, + "period": 50 + } + }, + { + "type": "hotp", + "uuid": "0a8c0571-ff6f-4b02-aa4b-50553b4fb4fe", + "name": "James", + "issuer": "Issuu", + "icon": null, + "icon_mime": null, + "group": "group2", + "info": { + "secret": "YOOMIXWS5GN6RTBPUFFWKTW5M4", + "algo": "SHA1", + "digits": 6, + "counter": 1 + } + }, + { + "type": "hotp", + "uuid": "03e572f2-8ebd-44b0-a57e-e958af74815d", + "name": "Benjamin", + "issuer": "Air Canada", + "icon": null, + "icon_mime": null, + "group": null, + "info": { + "secret": "KUVJJOM753IHTNDSZVCNKL7GII", + "algo": "SHA256", + "digits": 7, + "counter": 50 + } + }, + { + "type": "hotp", + "uuid": "b25f8815-007f-40f7-a700-ce058ac05435", + "name": "Mason", + "issuer": "WWE", + "icon": null, + "icon_mime": null, + "group": null, + "info": { + "secret": "5VAML3X35THCEBVRLV24CGBKOY", + "algo": "SHA512", + "digits": 8, + "counter": 10300 + } + }, + { + "type": "steam", + "uuid": "5b11ae3b-6fc3-4d46-8ca7-cf0aea7de920", + "name": "Sophia", + "issuer": "Boeing", + "icon": null, + "icon_mime": null, + "group": null, + "info": { + "secret": "JRZCL47CMXVOQMNPZR2F7J4RGI", + "algo": "SHA1", + "digits": 5, + "period": 30 + } + } + ] + } +} \ No newline at end of file