From 9131cae944f354c95873aa1c257e87b0c9b1f210 Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sat, 21 Sep 2024 10:11:17 +0200 Subject: [PATCH] Use DiffUtil for the RecyclerView of the entry list Gets rid of all of the custom logic we had for notifying the RecyclerView about changes in the entry list. This will allow for more simplifications in the future around non-persisted changes to state in the entry list. A neat side effect is that any filtering/ordering changes in the entry list are now also animated: https://alexbakker.me/u/4a4ie5yzpj.mp4 This touches the fundamentals of the entry list, so lots of careful testing required. --- .../SimpleItemTouchHelperCallback.java | 2 +- .../aegis/ui/MainActivity.java | 73 ++- .../aegis/ui/models/ErrorCardInfo.java | 23 + .../aegis/ui/views/EntryAdapter.java | 492 +++++++++--------- .../aegis/ui/views/EntryListView.java | 128 ++--- .../aegis/vault/VaultRepository.java | 12 + 6 files changed, 382 insertions(+), 348 deletions(-) diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java index 3192864f67..96c672bde1 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/SimpleItemTouchHelperCallback.java @@ -69,7 +69,7 @@ public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull Recycle int swipeFlags = 0; if (adapter.isPositionFooter(position) || adapter.isPositionErrorCard(position) - || adapter.getEntryAtPos(position) != _selectedEntry + || adapter.getEntryAtPosition(position) != _selectedEntry || !isLongPressDragEnabled()) { return makeMovementFlags(0, swipeFlags); } 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 d9c03a59ba..579db8f74d 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -149,7 +149,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { return; } - onAssignIconsResult(activityResult.getData()); + onAssignIconsResult(); }); private final ActivityResultLauncher preferenceResultLauncher = @@ -160,7 +160,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { return; } - onEditEntryResult(activityResult.getData()); + onEditEntryResult(); }); private final ActivityResultLauncher addEntryResultLauncher = @@ -255,13 +255,13 @@ public void setGroups(Collection groups) { _prefGroupFilter = null; if (!groupFilter.isEmpty()) { _groupFilter = groupFilter; - _entryListView.setGroupFilter(groupFilter, false); + _entryListView.setGroupFilter(groupFilter); } } else if (_groupFilter != null) { Set groupFilter = cleanGroupFilter(_groupFilter); if (!_groupFilter.equals(groupFilter)) { _groupFilter = groupFilter; - _entryListView.setGroupFilter(groupFilter, true); + _entryListView.setGroupFilter(groupFilter); } } @@ -316,7 +316,7 @@ private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) { if (!isChecked) { group1.setChecked(false); _groupFilter = groupFilter; - _entryListView.setGroupFilter(groupFilter, false); + _entryListView.setGroupFilter(groupFilter); return; } @@ -328,7 +328,7 @@ private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) { } _groupFilter = groupFilter; - _entryListView.setGroupFilter(groupFilter, false); + _entryListView.setGroupFilter(groupFilter); }); chipGroup.addView(chip); @@ -573,31 +573,20 @@ private void onAddEntryResult(Intent data) { if (_loaded) { UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID); - _entryListView.addEntry(entry, true); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); + _entryListView.onEntryAdded(entry); } } - private void onEditEntryResult(Intent data) { + private void onEditEntryResult() { if (_loaded) { - UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); - - if (data.getBooleanExtra("delete", false)) { - _entryListView.removeEntry(entryUUID); - } else { - VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID); - _entryListView.replaceEntry(entryUUID, entry); - } + _entryListView.setEntries(_vaultManager.getVault().getEntries()); } } - private void onAssignIconsResult(Intent data) { + private void onAssignIconsResult() { if (_loaded) { - ArrayList entryUUIDs = (ArrayList) data.getSerializableExtra("entryUUIDs"); - - for (UUID entryUUID: entryUUIDs) { - VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID); - _entryListView.replaceEntry(entryUUID, entry); - } + _entryListView.setEntries(_vaultManager.getVault().getEntries()); } } @@ -695,14 +684,11 @@ private void importScannedEntries(List entries) { if (entries.size() == 1) { startEditEntryActivityForNew(entries.get(0)); } else if (entries.size() > 1) { - for (VaultEntry entry: entries) { - _vaultManager.getVault().addEntry(entry); - _entryListView.addEntry(entry); - } - if (saveAndBackupVault()) { Toast.makeText(this, getResources().getQuantityString(R.plurals.added_new_entries, entries.size(), entries.size()), Toast.LENGTH_LONG).show(); } + + _entryListView.setEntries(_vaultManager.getVault().getEntries()); } } @@ -925,15 +911,6 @@ protected void onStart() { updateErrorCard(); } - private void deleteEntries(List entries) { - for (VaultEntry entry: entries) { - VaultEntry oldEntry = _vaultManager.getVault().removeEntry(entry); - _entryListView.removeEntry(oldEntry); - } - - saveAndBackupVault(); - } - @Override public boolean onCreateOptionsMenu(Menu menu) { _menu = menu; @@ -1063,7 +1040,7 @@ private void loadEntries() { setGroups(_vaultManager.getVault().getUsedGroups()); _entryListView.setUsageCounts(_prefs.getUsageCounts()); _entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps()); - _entryListView.addEntries(_vaultManager.getVault().getEntries()); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); if (!_isRecreated) { _entryListView.runEntriesAnimation(); } @@ -1291,6 +1268,13 @@ public void onLocked(boolean userInitiated) { } } + @Override + protected boolean saveAndBackupVault() { + boolean res = super.saveAndBackupVault(); + updateErrorCard(); + return res; + } + @SuppressLint("InlinedApi") private void copyEntryCode(VaultEntry entry) { String otp; @@ -1387,12 +1371,13 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { mode.finish(); } else if (itemId == R.id.action_toggle_favorite) { for (VaultEntry entry : _selectedEntries) { - entry.setIsFavorite(!entry.isFavorite()); - _entryListView.replaceEntry(entry.getUUID(), entry); + _vaultManager.getVault().editEntry(entry, newEntry -> { + newEntry.setIsFavorite(!newEntry.isFavorite()); + }); } - _entryListView.refresh(true); saveAndBackupVault(); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); mode.finish(); } else if (itemId == R.id.action_share_qr) { Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class); @@ -1410,8 +1395,12 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { mode.finish(); } else if (itemId == R.id.action_delete) { Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { - deleteEntries(_selectedEntries); + for (VaultEntry entry : _selectedEntries) { + _vaultManager.getVault().removeEntry(entry); + } + saveAndBackupVault(); _entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); + _entryListView.setEntries(_vaultManager.getVault().getEntries()); mode.finish(); }); } else if (itemId == R.id.action_select_all) { diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/models/ErrorCardInfo.java b/app/src/main/java/com/beemdevelopment/aegis/ui/models/ErrorCardInfo.java index c6385e565f..f46423350e 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/models/ErrorCardInfo.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/models/ErrorCardInfo.java @@ -2,6 +2,10 @@ import android.view.View; +import com.google.common.hash.HashCode; + +import java.util.Objects; + public class ErrorCardInfo { private final String _message; private final View.OnClickListener _listener; @@ -18,4 +22,23 @@ public String getMessage() { public View.OnClickListener getListener() { return _listener; } + + @Override + public int hashCode() { + return HashCode.fromString(_message).asInt(); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (!(o instanceof ErrorCardInfo)) { + return false; + } + + // This equality check purposefully ignores the onclick listener + ErrorCardInfo info = (ErrorCardInfo) o; + return Objects.equals(getMessage(), info.getMessage()); + } } 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 696c7f4000..d89ef2e7a1 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 @@ -15,6 +15,8 @@ import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import com.beemdevelopment.aegis.AccountNamePosition; @@ -49,8 +51,7 @@ public class EntryAdapter extends RecyclerView.Adapter implements ItemTouchHelperAdapter { private EntryListView _view; - private List _entries; - private List _shownEntries; + private EntryList _entryList; private List _selectedEntries; private Collection _groups; private Map _usageCounts; @@ -77,14 +78,12 @@ public class EntryAdapter extends RecyclerView.Adapter private Handler _dimHandler; private Handler _doubleTapHandler; private boolean _pauseFocused; - private ErrorCardInfo _errorCardInfo; // keeps track of the EntryHolders that are currently bound private List _holders; public EntryAdapter(EntryListView view) { - _entries = new ArrayList<>(); - _shownEntries = new ArrayList<>(); + _entryList = new EntryList(); _selectedEntries = new ArrayList<>(); _groupFilter = new TreeSet<>(); _holders = new ArrayList<>(); @@ -145,173 +144,45 @@ public void setPauseFocused(boolean pauseFocused) { } public void setErrorCardInfo(ErrorCardInfo info) { - ErrorCardInfo oldInfo = _errorCardInfo; - _errorCardInfo = info; - - if (oldInfo == null && info != null) { - notifyItemInserted(0); - } else if (oldInfo != null && info == null) { - notifyItemRemoved(0); - } else { - notifyItemChanged(0); + if (Objects.equals(info, _entryList.getErrorCardInfo())) { + return; } - } - public VaultEntry getEntryAtPos(int position) { - return _shownEntries.get(translateEntryPosToIndex(position)); + replaceEntryList(new EntryList( + _entryList.getEntries(), + _entryList.getShownEntries(), + info + )); } - public int addEntry(VaultEntry entry) { - _entries.add(entry); - if (isEntryFiltered(entry)) { - return -1; - } - - int position = -1; - Comparator comparator = _sortCategory.getComparator(); - if (comparator != null) { - // insert the entry in the correct order - // note: this assumes that _shownEntries has already been sorted - for (int i = getShownFavoritesCount(); i < _shownEntries.size(); i++) { - if (comparator.compare(_shownEntries.get(i), entry) > 0) { - _shownEntries.add(i, entry); - position = translateEntryIndexToPos(i); - notifyItemInserted(position); - break; - } - } - } - - if (position < 0) { - _shownEntries.add(entry); - - position = translateEntryIndexToPos(getShownEntriesCount() - 1); - if (position == 0) { - notifyDataSetChanged(); - } else { - notifyItemInserted(position); - } - } + public VaultEntry getEntryAtPosition(int position) { + return _entryList.getShownEntries().get(_entryList.translateEntryPosToIndex(position)); + } - _view.onListChange(); - checkPeriodUniformity(); - updateFooter(); - return position; + public int getEntryPosition(VaultEntry entry) { + return _entryList.translateEntryIndexToPos(_entryList.getShownEntries().indexOf(entry)); } - public void addEntries(Collection entries) { - for (VaultEntry entry: entries) { + public void setEntries(List entries) { + // TODO: Move these fields to separate dedicated model for the UI + for (VaultEntry entry : entries) { entry.setUsageCount(_usageCounts.containsKey(entry.getUUID()) ? _usageCounts.get(entry.getUUID()) : 0); entry.setLastUsedTimestamp(_lastUsedTimestamps.containsKey(entry.getUUID()) ? _lastUsedTimestamps.get(entry.getUUID()) : 0); } - _entries.addAll(entries); - updateShownEntries(); - checkPeriodUniformity(true); - } - - public void removeEntry(VaultEntry entry) { - _entries.remove(entry); - - if (_shownEntries.contains(entry)) { - int index = _shownEntries.indexOf(entry); - _shownEntries.remove(index); - - int position = translateEntryIndexToPos(index); - notifyItemRemoved(position); - - updateFooter(); - } - - _view.onListChange(); - checkPeriodUniformity(); - } - - public void removeEntry(UUID uuid) { - VaultEntry entry = getEntryByUUID(uuid); - removeEntry(entry); + replaceEntryList(new EntryList( + entries, + calculateShownEntries(entries), + _entryList.getErrorCardInfo() + )); } public void clearEntries() { - _entries.clear(); - _shownEntries.clear(); - notifyDataSetChanged(); - checkPeriodUniformity(); - } - - public void replaceEntry(UUID uuid, VaultEntry newEntry) { - VaultEntry oldEntry = getEntryByUUID(uuid); - _entries.set(_entries.indexOf(oldEntry), newEntry); - - if (_shownEntries.contains(oldEntry)) { - int index = _shownEntries.indexOf(oldEntry); - int position = translateEntryIndexToPos(index); - if (isEntryFiltered(newEntry)) { - _shownEntries.remove(index); - notifyItemRemoved(position); - } else { - _shownEntries.set(index, newEntry); - notifyItemChanged(position); - } - - sortShownEntries(); - int newIndex = _shownEntries.indexOf(newEntry); - int newPosition = translateEntryIndexToPos(newIndex); - if (newPosition != NO_POSITION && position != newPosition) { - notifyItemMoved(position, newPosition); - } - } else if (!isEntryFiltered(newEntry)) { - // NOTE: This logic is wrong, because sorting is not taken into account. This code - // path is currently never hit though, because it is not possible to edit an entry - // that is not shown. - _shownEntries.add(newEntry); - - int position = getItemCount() - 1; - notifyItemInserted(position); - } - - checkPeriodUniformity(); - updateFooter(); + replaceEntryList(new EntryList()); } - private VaultEntry getEntryByUUID(UUID uuid) { - for (VaultEntry entry : _entries) { - if (entry.getUUID().equals(uuid)) { - return entry; - } - } - - return null; - } - - /** - * Translates the given entry position in the recycler view, to its index in the shown entries list. - */ public int translateEntryPosToIndex(int position) { - if (position == NO_POSITION) { - return NO_POSITION; - } - - if (isErrorCardShown()) { - position -= 1; - } - - return position; - } - - /** - * Translates the given entry index in the shown entries list, to its position in the recycler view. - */ - private int translateEntryIndexToPos(int index) { - if (index == NO_POSITION) { - return NO_POSITION; - } - - if (isErrorCardShown()) { - index += 1; - } - - return index; + return _entryList.translateEntryPosToIndex(position); } private boolean isEntryFiltered(VaultEntry entry) { @@ -348,7 +219,7 @@ private boolean doesAnyGroupMatchSearchFilter(Set entryGroupUUIDs, String public void refresh(boolean hard) { if (hard) { - updateShownEntries(); + refreshEntryList(); } else { for (EntryHolder holder : _holders) { holder.refresh(); @@ -363,8 +234,7 @@ public void setGroupFilter(@NonNull Set groups) { } _groupFilter = groups; - updateShownEntries(); - checkPeriodUniformity(); + refreshEntryList(); } public void setSortCategory(SortCategory category, boolean apply) { @@ -374,7 +244,7 @@ public void setSortCategory(SortCategory category, boolean apply) { _sortCategory = category; if (apply) { - updateShownEntries(); + refreshEntryList(); } } @@ -383,25 +253,59 @@ public String getSearchFilter() { } public void setSearchFilter(String search) { - _searchFilter = (search != null && !search.isEmpty()) ? search.toLowerCase().trim() : null; - updateShownEntries(); + String newSearchFilter = (search != null && !search.isEmpty()) + ? search.toLowerCase().trim() : null; + + if (!Objects.equals(_searchFilter, newSearchFilter)) { + _searchFilter = newSearchFilter; + refreshEntryList(); + } } - private void updateShownEntries() { - // clear the list of shown entries first - _shownEntries.clear(); + private void refreshEntryList() { + replaceEntryList(new EntryList( + _entryList.getEntries(), + calculateShownEntries(_entryList.getEntries()), + _entryList.getErrorCardInfo() + )); + } - // add entries back that are not filtered out - for (VaultEntry entry : _entries) { + private void replaceEntryList(EntryList newEntryList) { + DiffUtil.DiffResult diffRes = DiffUtil.calculateDiff(new DiffCallback(_entryList, newEntryList)); + _entryList = newEntryList; + updatePeriodUniformity(); + + // This scroll position trick is required in order to not have the recycler view + // jump to some random position after a large change (like resorting entries) + // Related: https://issuetracker.google.com/issues/70149059 + int scrollPos = _view.getScrollPosition(); + diffRes.dispatchUpdatesTo(this); + _view.scrollToPosition(scrollPos); + _view.onListChange(); + } + + private List calculateShownEntries(List entries) { + List res = new ArrayList<>(); + for (VaultEntry entry : entries) { if (!isEntryFiltered(entry)) { - _shownEntries.add(entry); + res.add(entry); } } - sortShownEntries(); - checkPeriodUniformity(); - _view.onListChange(); - notifyDataSetChanged(); + sortEntries(res, _sortCategory); + return res; + } + + private static void sortEntries(List entries, SortCategory sortCategory) { + if (sortCategory != null) { + Comparator comparator = sortCategory.getComparator(); + if (comparator != null) { + Collections.sort(entries, comparator); + } + } + + Comparator favoriteComparator = new FavoriteComparator(); + Collections.sort(entries, favoriteComparator); } private boolean isEntryDraggable(VaultEntry entry) { @@ -412,18 +316,6 @@ && isDragAndDropAllowed() && _selectedEntries.get(0) == entry; } - private void sortShownEntries() { - if (_sortCategory != null) { - Comparator comparator = _sortCategory.getComparator(); - if (comparator != null) { - Collections.sort(_shownEntries, comparator); - } - } - - Comparator favoriteComparator = new FavoriteComparator(); - Collections.sort(_shownEntries, favoriteComparator); - } - public void setViewMode(ViewMode viewMode) { _viewMode = viewMode; } @@ -439,7 +331,7 @@ public void setViewMode(ViewMode viewMode) { public Map getLastUsedTimestamps() { return _lastUsedTimestamps; } public int getShownFavoritesCount() { - return (int) _shownEntries.stream().filter(VaultEntry::isFavorite).count(); + return (int) _entryList.getShownEntries().stream().filter(VaultEntry::isFavorite).count(); } @Override @@ -451,43 +343,48 @@ public void onItemDismiss(int position) { public void onItemDrop(int position) { // moving entries is not allowed when a filter is applied // footer cant be moved, nor can items be moved below it - if (!_groupFilter.isEmpty() || isPositionFooter(position) || isPositionErrorCard(position)) { + if (!_groupFilter.isEmpty() || _entryList.isPositionFooter(position) || _entryList.isPositionErrorCard(position)) { return; } - int index = translateEntryPosToIndex(position); - _view.onEntryDrop(_shownEntries.get(index)); + int index = _entryList.translateEntryPosToIndex(position); + _view.onEntryDrop(_entryList.getShownEntries().get(index)); } @Override public void onItemMove(int firstPosition, int secondPosition) { - // moving entries is not allowed when a filter is applied - // footer cant be moved, nor can items be moved below it + // Moving entries is not allowed when a filter is applied. The footer can't be + // moved, nor can items be moved below it if (!_groupFilter.isEmpty() - || isPositionFooter(firstPosition) || isPositionFooter(secondPosition) - || isPositionErrorCard(firstPosition) || isPositionErrorCard(secondPosition)) { + || _entryList.isPositionFooter(firstPosition) || _entryList.isPositionFooter(secondPosition) + || _entryList.isPositionErrorCard(firstPosition) || _entryList.isPositionErrorCard(secondPosition)) { return; } - // notify the vault first - int firstIndex = translateEntryPosToIndex(firstPosition); - int secondIndex = translateEntryPosToIndex(secondPosition); - _view.onEntryMove(_entries.get(firstIndex), _entries.get(secondIndex)); - - // then update our end - CollectionUtils.move(_entries, firstIndex, secondIndex); - CollectionUtils.move(_shownEntries, firstIndex, secondIndex); + // Notify the vault about the entry position change first + int firstIndex = _entryList.translateEntryPosToIndex(firstPosition); + int secondIndex = _entryList.translateEntryPosToIndex(secondPosition); + VaultEntry firstEntry = _entryList.getShownEntries().get(firstIndex); + VaultEntry secondEntry = _entryList.getShownEntries().get(secondIndex); + _view.onEntryMove(firstEntry, secondEntry); - notifyItemMoved(firstPosition, secondPosition); + // Then update the visual end + List newEntries = new ArrayList<>(_entryList.getEntries()); + CollectionUtils.move(newEntries, newEntries.indexOf(firstEntry), newEntries.indexOf(secondEntry)); + replaceEntryList(new EntryList( + newEntries, + calculateShownEntries(newEntries), + _entryList.getErrorCardInfo() + )); } @Override public int getItemViewType(int position) { - if (isPositionErrorCard(position)) { + if (_entryList.isPositionErrorCard(position)) { return R.layout.card_error; } - if (isPositionFooter(position)) { + if (_entryList.isPositionFooter(position)) { return R.layout.card_footer; } @@ -502,7 +399,7 @@ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType View view = inflater.inflate(viewType, parent, false); if (viewType == R.layout.card_error) { - holder = new ErrorCardHolder(view, _errorCardInfo); + holder = new ErrorCardHolder(view, Objects.requireNonNull(_entryList.getErrorCardInfo())); } else if (viewType == R.layout.card_footer) { holder = new FooterView(view); } else { @@ -528,8 +425,8 @@ public void onViewRecycled(RecyclerView.ViewHolder holder) { public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) { if (holder instanceof EntryHolder) { EntryHolder entryHolder = (EntryHolder) holder; - int index = translateEntryPosToIndex(position); - VaultEntry entry = _shownEntries.get(index); + int index = _entryList.translateEntryPosToIndex(position); + VaultEntry entry = _entryList.getShownEntries().get(index); boolean hidden = _tapToReveal && entry != _focusedEntry; boolean paused = _pauseFocused && entry == _focusedEntry; @@ -538,7 +435,7 @@ public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) boolean showAccountName = true; if (_onlyShowNecessaryAccountNames) { // Only show account name when there's multiple entries found with the same issuer. - showAccountName = _entries.stream() + showAccountName = _entryList.getEntries().stream() .filter(x -> x.getIssuer().equals(entry.getIssuer())) .count() > 1; } @@ -616,8 +513,8 @@ public boolean onLongClick(View v) { entryHolder.setFocusedAndAnimate(true); } - int index = translateEntryPosToIndex(position); - boolean returnVal = _view.onLongEntryClick(_shownEntries.get(index)); + int index = _entryList.translateEntryPosToIndex(position); + boolean returnVal = _view.onLongEntryClick(_entryList.getShownEntries().get(index)); if (_selectedEntries.size() == 0 || isEntryDraggable(entry)) { _view.startDrag(entryHolder); } @@ -663,15 +560,10 @@ public void onClick(View v) { } } - private void checkPeriodUniformity() { - checkPeriodUniformity(false); - } - - private void checkPeriodUniformity(boolean force) { + private void updatePeriodUniformity() { int mostFrequentPeriod = getMostFrequentPeriod(); boolean uniform = isPeriodUniform(); - - if (!force && uniform == _isPeriodUniform && mostFrequentPeriod == _uniformPeriod) { + if (uniform == _isPeriodUniform && mostFrequentPeriod == _uniformPeriod) { return; } @@ -689,7 +581,7 @@ private void checkPeriodUniformity(boolean force) { public int getMostFrequentPeriod() { List infos = new ArrayList<>(); - for (VaultEntry entry : _shownEntries) { + for (VaultEntry entry : _entryList.getShownEntries()) { OtpInfo info = entry.getInfo(); if (info instanceof TotpInfo) { infos.add((TotpInfo) info); @@ -804,7 +696,7 @@ public void addSelectedEntry(VaultEntry entry) { public List selectAllEntries() { _selectedEntries.clear(); - for (VaultEntry entry: _shownEntries) { + for (VaultEntry entry: _entryList.getShownEntries()) { for (EntryHolder holder: _holders) { if (holder.getEntry() == entry) { holder.setFocused(true); @@ -858,34 +750,23 @@ private static boolean isPeriodUniform(int period) { @Override public int getItemCount() { - // Always at least one item because of the footer - // Two in case there's also an error card - int baseCount = 1; - if (isErrorCardShown()) { - baseCount++; - } - - return baseCount + getShownEntriesCount(); + return _entryList.getItemCount(); } public int getShownEntriesCount() { - return _shownEntries.size(); + return _entryList.getShownEntries().size(); } public boolean isPositionFooter(int position) { - return position == (getItemCount() - 1); + return _entryList.isPositionFooter(position); } public boolean isPositionErrorCard(int position) { - return isErrorCardShown() && position == 0; + return _entryList.isPositionErrorCard(position); } public boolean isErrorCardShown() { - return _errorCardInfo != null; - } - - private void updateFooter() { - notifyItemChanged(getItemCount() - 1); + return _entryList.isErrorCardShown(); } private class FooterView extends RecyclerView.ViewHolder { @@ -912,6 +793,151 @@ public void refresh() { } } + private static class EntryList { + private final List _entries; + private final List _shownEntries; + private final ErrorCardInfo _errorCardInfo; + + public EntryList() { + this(new ArrayList<>(), new ArrayList<>(), null); + } + + public EntryList( + @NonNull List entries, + @NonNull List shownEntries, + @Nullable ErrorCardInfo errorCardInfo + ) { + _entries = entries; + _shownEntries = shownEntries; + _errorCardInfo = errorCardInfo; + } + + public List getEntries() { + return _entries; + } + + public List getShownEntries() { + return _shownEntries; + } + + public int getItemCount() { + // Always at least one item because of the footer + // Two in case there's also an error card + int baseCount = 1; + if (isErrorCardShown()) { + baseCount++; + } + + return baseCount + getShownEntries().size(); + } + + @Nullable + public ErrorCardInfo getErrorCardInfo() { + return _errorCardInfo; + } + + public boolean isErrorCardShown() { + return _errorCardInfo != null; + } + + public boolean isPositionErrorCard(int position) { + return isErrorCardShown() && position == 0; + } + + public boolean isPositionFooter(int position) { + return position == (getItemCount() - 1); + } + + /** + * Translates the given entry position in the recycler view, to its index in the shown entries list. + */ + public int translateEntryPosToIndex(int position) { + if (position == NO_POSITION) { + return NO_POSITION; + } + + if (isErrorCardShown()) { + position -= 1; + } + + return position; + } + + /** + * Translates the given entry index in the shown entries list, to its position in the recycler view. + */ + public int translateEntryIndexToPos(int index) { + if (index == NO_POSITION) { + return NO_POSITION; + } + + if (isErrorCardShown()) { + index += 1; + } + + return index; + } + + } + + private static class DiffCallback extends DiffUtil.Callback { + private final EntryList _old; + private final EntryList _new; + + public DiffCallback(EntryList oldList, EntryList newList) { + _old = oldList; + _new = newList; + } + + @Override + public int getOldListSize() { + return _old.getItemCount(); + } + + @Override + public int getNewListSize() { + return _new.getItemCount(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + if (_old.isPositionErrorCard(oldItemPosition) != _new.isPositionErrorCard(newItemPosition) + || _old.isPositionFooter(oldItemPosition) != _new.isPositionFooter(newItemPosition)) { + return false; + } + + if ((_old.isPositionFooter(oldItemPosition) && _new.isPositionFooter(newItemPosition)) + || (_old.isPositionErrorCard(oldItemPosition) && _new.isPositionErrorCard(newItemPosition))) { + return true; + } + + int oldEntryIndex = _old.translateEntryPosToIndex(oldItemPosition); + int newEntryIndex = _new.translateEntryPosToIndex(newItemPosition); + if (oldEntryIndex < 0 || newEntryIndex < 0) { + return false; + } + + return _old.getShownEntries().get(oldEntryIndex).getUUID() + .equals(_new.getShownEntries().get(newEntryIndex).getUUID()); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + if (_old.isPositionFooter(oldItemPosition) && _new.isPositionFooter(newItemPosition)) { + return _old.getShownEntries().size() == _new.getShownEntries().size(); + } + + if (_old.isPositionErrorCard(oldItemPosition) && _new.isPositionErrorCard(newItemPosition)) { + return Objects.equals(_old.getErrorCardInfo(), _new.getErrorCardInfo()); + } + + int oldEntryIndex = _old.translateEntryPosToIndex(oldItemPosition); + int newEntryIndex = _new.translateEntryPosToIndex(newItemPosition); + return _old.getShownEntries().get(oldEntryIndex) + .equals(_new.getShownEntries().get(newEntryIndex)); + } + } + public interface Listener { void onEntryClick(VaultEntry entry); boolean onLongEntryClick(VaultEntry entry); 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 af829d6dac..a5b47c54a2 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 @@ -50,6 +50,7 @@ import com.google.android.material.shape.ShapeAppearanceModel; import com.google.common.base.Strings; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -156,6 +157,14 @@ public void setPreloadView(View view) { _preloadSizeProvider.setView(view); } + public int getScrollPosition() { + return ((LinearLayoutManager) _recyclerView.getLayoutManager()).findFirstVisibleItemPosition(); + } + + public void scrollToPosition(int position) { + _recyclerView.getLayoutManager().scrollToPosition(position); + } + @Override public void onDestroyView() { _refresher.destroy(); @@ -167,14 +176,10 @@ public void setGroups(Collection groups) { updateDividerDecoration(); } - public void setGroupFilter(Set groups, boolean animate) { + public void setGroupFilter(Set groups) { _adapter.setGroupFilter(groups); _touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed()); updateEmptyState(); - - if (animate) { - runEntriesAnimation(); - } } public void setIsLongPressDragEnabled(boolean enabled) { @@ -207,10 +212,6 @@ public void setActionModeState(boolean enabled, VaultEntry entry) { public void setSortCategory(SortCategory sortCategory, boolean apply) { _adapter.setSortCategory(sortCategory, apply); _touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed()); - - if (apply) { - runEntriesAnimation(); - } } public void setUsageCounts(Map usageCounts) { @@ -388,61 +389,57 @@ public void setErrorCardInfo(ErrorCardInfo info) { _adapter.setErrorCardInfo(info); } - public void addEntry(VaultEntry entry) { - addEntry(entry, false); - } - @SuppressLint("ClickableViewAccessibility") - public void addEntry(VaultEntry entry, boolean focusEntry) { - int position = _adapter.addEntry(entry); - updateEmptyState(); + public void onEntryAdded(VaultEntry entry) { + int position = _adapter.getEntryPosition(entry); + if (position < 0) { + return; + } LinearLayoutManager layoutManager = (LinearLayoutManager) _recyclerView.getLayoutManager(); - if (focusEntry && position >= 0) { - if ((_recyclerView.canScrollVertically(1) && position > layoutManager.findLastCompletelyVisibleItemPosition()) - || (_recyclerView.canScrollVertically(-1) && position < layoutManager.findFirstCompletelyVisibleItemPosition())) { - boolean smoothScroll = !AnimationsHelper.Scale.TRANSITION.isZero(requireContext()); - RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() { - private void handleScroll() { - _recyclerView.removeOnScrollListener(this); - _recyclerView.setOnTouchListener(null); - tempHighlightEntry(entry); - } + if ((_recyclerView.canScrollVertically(1) && position > layoutManager.findLastCompletelyVisibleItemPosition()) + || (_recyclerView.canScrollVertically(-1) && position < layoutManager.findFirstCompletelyVisibleItemPosition())) { + boolean smoothScroll = !AnimationsHelper.Scale.TRANSITION.isZero(requireContext()); + RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() { + private void handleScroll() { + _recyclerView.removeOnScrollListener(this); + _recyclerView.setOnTouchListener(null); + tempHighlightEntry(entry); + } - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - if (smoothScroll && newState == RecyclerView.SCROLL_STATE_IDLE) { - handleScroll(); - } + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + if (smoothScroll && newState == RecyclerView.SCROLL_STATE_IDLE) { + handleScroll(); } + } - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - if (!smoothScroll) { - handleScroll(); - } + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (!smoothScroll) { + handleScroll(); } - }; - _recyclerView.addOnScrollListener(scrollListener); - _recyclerView.setOnTouchListener((v, event) -> { - if (event.getAction() == MotionEvent.ACTION_DOWN) { - _recyclerView.removeOnScrollListener(scrollListener); - _recyclerView.stopScroll(); - _recyclerView.setOnTouchListener(null); - } - - return false; - }); - // We can't easily control the speed of the smooth scroll animation, but we - // can at least disable it if animations are disabled - if (smoothScroll) { - _recyclerView.smoothScrollToPosition(position); - } else { - _recyclerView.scrollToPosition(position); } + }; + _recyclerView.addOnScrollListener(scrollListener); + _recyclerView.setOnTouchListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + _recyclerView.removeOnScrollListener(scrollListener); + _recyclerView.stopScroll(); + _recyclerView.setOnTouchListener(null); + } + + return false; + }); + // We can't easily control the speed of the smooth scroll animation, but we + // can at least disable it if animations are disabled + if (smoothScroll) { + _recyclerView.smoothScrollToPosition(position); } else { - tempHighlightEntry(entry); + _recyclerView.scrollToPosition(position); } + } else { + tempHighlightEntry(entry); } } @@ -453,27 +450,14 @@ public void tempHighlightEntry(VaultEntry entry) { _adapter.focusEntry(entry, secondsToFocus); } - public void addEntries(Collection entries) { - _adapter.addEntries(entries); - updateEmptyState(); - } - - public void removeEntry(VaultEntry entry) { - _adapter.removeEntry(entry); - updateEmptyState(); - } - - public void removeEntry(UUID uuid) { - _adapter.removeEntry(uuid); + public void setEntries(Collection entries) { + _adapter.setEntries(new ArrayList<>(entries)); updateEmptyState(); } public void clearEntries() { _adapter.clearEntries(); - } - - public void replaceEntry(UUID uuid, VaultEntry newEntry) { - _adapter.replaceEntry(uuid, newEntry); + updateEmptyState(); } public void runEntriesAnimation() { @@ -572,7 +556,7 @@ public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull R // Only non-favorite entries have a bottom margin, except for the final favorite entry int totalFavorites = _adapter.getShownFavoritesCount(); if (totalFavorites == 0 - || (entryIndex < _adapter.getShownEntriesCount() && !_adapter.getEntryAtPos(adapterPosition).isFavorite()) + || (entryIndex < _adapter.getShownEntriesCount() && !_adapter.getEntryAtPosition(adapterPosition).isFavorite()) || totalFavorites == entryIndex + 1) { outRect.bottom = _offset; } @@ -665,7 +649,7 @@ public List getPreloadItems(int position) { return Collections.emptyList(); } - VaultEntry entry = _adapter.getEntryAtPos(position); + VaultEntry entry = _adapter.getEntryAtPosition(position); if (!entry.hasIcon()) { return Collections.emptyList(); } 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 b96522f0b1..7792c953bc 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java +++ b/app/src/main/java/com/beemdevelopment/aegis/vault/VaultRepository.java @@ -7,6 +7,7 @@ import androidx.core.util.AtomicFile; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; +import com.beemdevelopment.aegis.util.Cloner; import com.beemdevelopment.aegis.util.IOUtils; import com.google.zxing.WriterException; @@ -249,6 +250,13 @@ public VaultEntry replaceEntry(VaultEntry entry) { return _vault.getEntries().replace(entry); } + public VaultEntry editEntry(VaultEntry entry, EntryEditor editor) { + VaultEntry newEntry = Cloner.clone(entry); + editor.edit(newEntry); + replaceEntry(newEntry); + return newEntry; + } + /** * Moves entry1 to the position of entry2. */ @@ -344,4 +352,8 @@ public boolean isBackupPasswordSet() { return getCredentials().getSlots().findBackupPasswordSlots().size() > 0; } + + public interface EntryEditor { + void edit(VaultEntry entry); + } }