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.
pull/1505/head
Alexander Bakker 5 months ago
parent 08d900c0c0
commit 9131cae944

@ -69,7 +69,7 @@ public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
int swipeFlags = 0;
if (adapter.isPositionFooter(position)
|| adapter.isPositionErrorCard(position)
|| adapter.getEntryAtPos(position) != _selectedEntry
|| adapter.getEntryAtPosition(position) != _selectedEntry
|| !isLongPressDragEnabled()) {
return makeMovementFlags(0, swipeFlags);
}

@ -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<Intent> 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<Intent> addEntryResultLauncher =
@ -255,13 +255,13 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
_prefGroupFilter = null;
if (!groupFilter.isEmpty()) {
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false);
_entryListView.setGroupFilter(groupFilter);
}
} else if (_groupFilter != null) {
Set<UUID> groupFilter = cleanGroupFilter(_groupFilter);
if (!_groupFilter.equals(groupFilter)) {
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, true);
_entryListView.setGroupFilter(groupFilter);
}
}
@ -316,7 +316,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
if (!isChecked) {
group1.setChecked(false);
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false);
_entryListView.setGroupFilter(groupFilter);
return;
}
@ -328,7 +328,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
}
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false);
_entryListView.setGroupFilter(groupFilter);
});
chipGroup.addView(chip);
@ -573,31 +573,20 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
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<UUID> entryUUIDs = (ArrayList<UUID>) 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 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
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 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
updateErrorCard();
}
private void deleteEntries(List<VaultEntry> 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 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
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 class MainActivity extends AegisActivity implements EntryListView.Listene
}
}
@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 class MainActivity extends AegisActivity implements EntryListView.Listene
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 class MainActivity extends AegisActivity implements EntryListView.Listene
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) {

@ -2,6 +2,10 @@ package com.beemdevelopment.aegis.ui.models;
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 class ErrorCardInfo {
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());
}
}

@ -15,6 +15,8 @@ import android.view.ViewGroup;
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 @@ import java.util.UUID;
public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements ItemTouchHelperAdapter {
private EntryListView _view;
private List<VaultEntry> _entries;
private List<VaultEntry> _shownEntries;
private EntryList _entryList;
private List<VaultEntry> _selectedEntries;
private Collection<VaultGroup> _groups;
private Map<UUID, Integer> _usageCounts;
@ -77,14 +78,12 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
private Handler _dimHandler;
private Handler _doubleTapHandler;
private boolean _pauseFocused;
private ErrorCardInfo _errorCardInfo;
// keeps track of the EntryHolders that are currently bound
private List<EntryHolder> _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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
}
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<VaultEntry> 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<VaultEntry> entries) {
for (VaultEntry entry: entries) {
public void setEntries(List<VaultEntry> 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 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public void refresh(boolean hard) {
if (hard) {
updateShownEntries();
refreshEntryList();
} else {
for (EntryHolder holder : _holders) {
holder.refresh();
@ -363,8 +234,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
}
_groupFilter = groups;
updateShownEntries();
checkPeriodUniformity();
refreshEntryList();
}
public void setSortCategory(SortCategory category, boolean apply) {
@ -374,7 +244,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
_sortCategory = category;
if (apply) {
updateShownEntries();
refreshEntryList();
}
}
@ -383,25 +253,59 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
}
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<VaultEntry> calculateShownEntries(List<VaultEntry> entries) {
List<VaultEntry> 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<VaultEntry> entries, SortCategory sortCategory) {
if (sortCategory != null) {
Comparator<VaultEntry> comparator = sortCategory.getComparator();
if (comparator != null) {
Collections.sort(entries, comparator);
}
}
Comparator<VaultEntry> favoriteComparator = new FavoriteComparator();
Collections.sort(entries, favoriteComparator);
}
private boolean isEntryDraggable(VaultEntry entry) {
@ -412,18 +316,6 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
&& _selectedEntries.get(0) == entry;
}
private void sortShownEntries() {
if (_sortCategory != null) {
Comparator<VaultEntry> comparator = _sortCategory.getComparator();
if (comparator != null) {
Collections.sort(_shownEntries, comparator);
}
}
Comparator<VaultEntry> favoriteComparator = new FavoriteComparator();
Collections.sort(_shownEntries, favoriteComparator);
}
public void setViewMode(ViewMode viewMode) {
_viewMode = viewMode;
}
@ -439,7 +331,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public Map<UUID, Long> 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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
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<VaultEntry> 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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
}
}
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 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public int getMostFrequentPeriod() {
List<TotpInfo> 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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public List<VaultEntry> 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 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
@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 class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
}
}
private static class EntryList {
private final List<VaultEntry> _entries;
private final List<VaultEntry> _shownEntries;
private final ErrorCardInfo _errorCardInfo;
public EntryList() {
this(new ArrayList<>(), new ArrayList<>(), null);
}
public EntryList(
@NonNull List<VaultEntry> entries,
@NonNull List<VaultEntry> shownEntries,
@Nullable ErrorCardInfo errorCardInfo
) {
_entries = entries;
_shownEntries = shownEntries;
_errorCardInfo = errorCardInfo;
}
public List<VaultEntry> getEntries() {
return _entries;
}
public List<VaultEntry> 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);

@ -50,6 +50,7 @@ import com.google.android.material.shape.CornerFamily;
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 class EntryListView extends Fragment implements EntryAdapter.Listener {
_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 class EntryListView extends Fragment implements EntryAdapter.Listener {
updateDividerDecoration();
}
public void setGroupFilter(Set<UUID> groups, boolean animate) {
public void setGroupFilter(Set<UUID> groups) {
_adapter.setGroupFilter(groups);
_touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed());
updateEmptyState();
if (animate) {
runEntriesAnimation();
}
}
public void setIsLongPressDragEnabled(boolean enabled) {
@ -207,10 +212,6 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
public void setSortCategory(SortCategory sortCategory, boolean apply) {
_adapter.setSortCategory(sortCategory, apply);
_touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed());
if (apply) {
runEntriesAnimation();
}
}
public void setUsageCounts(Map<UUID, Integer> usageCounts) {
@ -388,61 +389,57 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
_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 class EntryListView extends Fragment implements EntryAdapter.Listener {
_adapter.focusEntry(entry, secondsToFocus);
}
public void addEntries(Collection<VaultEntry> 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<VaultEntry> 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 class EntryListView extends Fragment implements EntryAdapter.Listener {
// 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 class EntryListView extends Fragment implements EntryAdapter.Listener {
return Collections.emptyList();
}
VaultEntry entry = _adapter.getEntryAtPos(position);
VaultEntry entry = _adapter.getEntryAtPosition(position);
if (!entry.hasIcon()) {
return Collections.emptyList();
}

@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
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 class VaultRepository {
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 class VaultRepository {
return getCredentials().getSlots().findBackupPasswordSlots().size() > 0;
}
public interface EntryEditor {
void edit(VaultEntry entry);
}
}

Loading…
Cancel
Save