Merge pull request #1505 from alexbakker/diffutil

Use DiffUtil for the RecyclerView of the entry list
This commit is contained in:
Michael Schättgen 2024-10-23 22:34:23 +02:00 committed by GitHub
commit bc29242f55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 386 additions and 352 deletions

View file

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

View file

@ -149,7 +149,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) { if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) {
return; return;
} }
onAssignIconsResult(activityResult.getData()); onAssignIconsResult();
}); });
private final ActivityResultLauncher<Intent> preferenceResultLauncher = 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) { if (activityResult.getResultCode() != RESULT_OK || activityResult.getData() == null) {
return; return;
} }
onEditEntryResult(activityResult.getData()); onEditEntryResult();
}); });
private final ActivityResultLauncher<Intent> addEntryResultLauncher = private final ActivityResultLauncher<Intent> addEntryResultLauncher =
@ -256,13 +256,13 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
_prefGroupFilter = null; _prefGroupFilter = null;
if (!groupFilter.isEmpty()) { if (!groupFilter.isEmpty()) {
_groupFilter = groupFilter; _groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false); _entryListView.setGroupFilter(groupFilter);
} }
} else if (_groupFilter != null) { } else if (_groupFilter != null) {
Set<UUID> groupFilter = cleanGroupFilter(_groupFilter); Set<UUID> groupFilter = cleanGroupFilter(_groupFilter);
if (!_groupFilter.equals(groupFilter)) { if (!_groupFilter.equals(groupFilter)) {
_groupFilter = groupFilter; _groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, true); _entryListView.setGroupFilter(groupFilter);
} }
} }
@ -317,7 +317,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
if (!isChecked) { if (!isChecked) {
group1.setChecked(false); group1.setChecked(false);
_groupFilter = groupFilter; _groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false); _entryListView.setGroupFilter(groupFilter);
return; return;
} }
@ -329,7 +329,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
} }
_groupFilter = groupFilter; _groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false); _entryListView.setGroupFilter(groupFilter);
}); });
chipGroup.addView(chip); chipGroup.addView(chip);
@ -574,31 +574,20 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
if (_loaded) { if (_loaded) {
UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID");
VaultEntry entry = _vaultManager.getVault().getEntryByUUID(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) { if (_loaded) {
UUID entryUUID = (UUID) data.getSerializableExtra("entryUUID"); _entryListView.setEntries(_vaultManager.getVault().getEntries());
if (data.getBooleanExtra("delete", false)) {
_entryListView.removeEntry(entryUUID);
} else {
VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID);
_entryListView.replaceEntry(entryUUID, entry);
}
} }
} }
private void onAssignIconsResult(Intent data) { private void onAssignIconsResult() {
if (_loaded) { if (_loaded) {
ArrayList<UUID> entryUUIDs = (ArrayList<UUID>) data.getSerializableExtra("entryUUIDs"); _entryListView.setEntries(_vaultManager.getVault().getEntries());
for (UUID entryUUID: entryUUIDs) {
VaultEntry entry = _vaultManager.getVault().getEntryByUUID(entryUUID);
_entryListView.replaceEntry(entryUUID, entry);
}
} }
} }
@ -696,14 +685,11 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
if (entries.size() == 1) { if (entries.size() == 1) {
startEditEntryActivityForNew(entries.get(0)); startEditEntryActivityForNew(entries.get(0));
} else if (entries.size() > 1) { } else if (entries.size() > 1) {
for (VaultEntry entry: entries) {
_vaultManager.getVault().addEntry(entry);
_entryListView.addEntry(entry);
}
if (saveAndBackupVault()) { if (saveAndBackupVault()) {
Toast.makeText(this, getResources().getQuantityString(R.plurals.added_new_entries, entries.size(), entries.size()), Toast.LENGTH_LONG).show(); Toast.makeText(this, getResources().getQuantityString(R.plurals.added_new_entries, entries.size(), entries.size()), Toast.LENGTH_LONG).show();
} }
_entryListView.setEntries(_vaultManager.getVault().getEntries());
} }
} }
@ -926,15 +912,6 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
updateErrorCard(); updateErrorCard();
} }
private void deleteEntries(List<VaultEntry> entries) {
for (VaultEntry entry: entries) {
VaultEntry oldEntry = _vaultManager.getVault().removeEntry(entry);
_entryListView.removeEntry(oldEntry);
}
saveAndBackupVault();
}
@Override @Override
public boolean onCreateOptionsMenu(Menu menu) { public boolean onCreateOptionsMenu(Menu menu) {
_menu = menu; _menu = menu;
@ -1064,7 +1041,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
setGroups(_vaultManager.getVault().getUsedGroups()); setGroups(_vaultManager.getVault().getUsedGroups());
_entryListView.setUsageCounts(_prefs.getUsageCounts()); _entryListView.setUsageCounts(_prefs.getUsageCounts());
_entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps()); _entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps());
_entryListView.addEntries(_vaultManager.getVault().getEntries()); _entryListView.setEntries(_vaultManager.getVault().getEntries());
if (!_isRecreated) { if (!_isRecreated) {
_entryListView.runEntriesAnimation(); _entryListView.runEntriesAnimation();
} }
@ -1292,6 +1269,13 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
} }
} }
@Override
protected boolean saveAndBackupVault() {
boolean res = super.saveAndBackupVault();
updateErrorCard();
return res;
}
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private void copyEntryCode(VaultEntry entry) { private void copyEntryCode(VaultEntry entry) {
String otp; String otp;
@ -1388,12 +1372,13 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
mode.finish(); mode.finish();
} else if (itemId == R.id.action_toggle_favorite) { } else if (itemId == R.id.action_toggle_favorite) {
for (VaultEntry entry : _selectedEntries) { for (VaultEntry entry : _selectedEntries) {
entry.setIsFavorite(!entry.isFavorite()); _vaultManager.getVault().editEntry(entry, newEntry -> {
_entryListView.replaceEntry(entry.getUUID(), entry); newEntry.setIsFavorite(!newEntry.isFavorite());
});
} }
_entryListView.refresh(true);
saveAndBackupVault(); saveAndBackupVault();
_entryListView.setEntries(_vaultManager.getVault().getEntries());
mode.finish(); mode.finish();
} else if (itemId == R.id.action_share_qr) { } else if (itemId == R.id.action_share_qr) {
Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class); Intent intent = new Intent(getBaseContext(), TransferEntriesActivity.class);
@ -1411,8 +1396,12 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
mode.finish(); mode.finish();
} else if (itemId == R.id.action_delete) { } else if (itemId == R.id.action_delete) {
Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> { Dialogs.showDeleteEntriesDialog(MainActivity.this, _selectedEntries, (d, which) -> {
deleteEntries(_selectedEntries); for (VaultEntry entry : _selectedEntries) {
_vaultManager.getVault().removeEntry(entry);
}
saveAndBackupVault();
_entryListView.setGroups(_vaultManager.getVault().getUsedGroups()); _entryListView.setGroups(_vaultManager.getVault().getUsedGroups());
_entryListView.setEntries(_vaultManager.getVault().getEntries());
mode.finish(); mode.finish();
}); });
} else if (itemId == R.id.action_select_all) { } else if (itemId == R.id.action_select_all) {

View file

@ -2,6 +2,10 @@ package com.beemdevelopment.aegis.ui.models;
import android.view.View; import android.view.View;
import com.google.common.hash.HashCode;
import java.util.Objects;
public class ErrorCardInfo { public class ErrorCardInfo {
private final String _message; private final String _message;
private final View.OnClickListener _listener; private final View.OnClickListener _listener;
@ -18,4 +22,23 @@ public class ErrorCardInfo {
public View.OnClickListener getListener() { public View.OnClickListener getListener() {
return _listener; 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());
}
} }

View file

@ -15,6 +15,8 @@ import android.view.ViewGroup;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.beemdevelopment.aegis.AccountNamePosition; import com.beemdevelopment.aegis.AccountNamePosition;
@ -49,8 +51,7 @@ import java.util.UUID;
public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements ItemTouchHelperAdapter { public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements ItemTouchHelperAdapter {
private EntryListView _view; private EntryListView _view;
private List<VaultEntry> _entries; private EntryList _entryList;
private List<VaultEntry> _shownEntries;
private List<VaultEntry> _selectedEntries; private List<VaultEntry> _selectedEntries;
private Collection<VaultGroup> _groups; private Collection<VaultGroup> _groups;
private Map<UUID, Integer> _usageCounts; private Map<UUID, Integer> _usageCounts;
@ -78,14 +79,12 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
private Handler _dimHandler; private Handler _dimHandler;
private Handler _doubleTapHandler; private Handler _doubleTapHandler;
private boolean _pauseFocused; private boolean _pauseFocused;
private ErrorCardInfo _errorCardInfo;
// keeps track of the EntryHolders that are currently bound // keeps track of the EntryHolders that are currently bound
private List<EntryHolder> _holders; private List<EntryHolder> _holders;
public EntryAdapter(EntryListView view) { public EntryAdapter(EntryListView view) {
_entries = new ArrayList<>(); _entryList = new EntryList();
_shownEntries = new ArrayList<>();
_selectedEntries = new ArrayList<>(); _selectedEntries = new ArrayList<>();
_groupFilter = new TreeSet<>(); _groupFilter = new TreeSet<>();
_holders = new ArrayList<>(); _holders = new ArrayList<>();
@ -150,173 +149,45 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
} }
public void setErrorCardInfo(ErrorCardInfo info) { public void setErrorCardInfo(ErrorCardInfo info) {
ErrorCardInfo oldInfo = _errorCardInfo; if (Objects.equals(info, _entryList.getErrorCardInfo())) {
_errorCardInfo = info; return;
if (oldInfo == null && info != null) {
notifyItemInserted(0);
} else if (oldInfo != null && info == null) {
notifyItemRemoved(0);
} else {
notifyItemChanged(0);
} }
replaceEntryList(new EntryList(
_entryList.getEntries(),
_entryList.getShownEntries(),
info
));
} }
public VaultEntry getEntryAtPos(int position) { public VaultEntry getEntryAtPosition(int position) {
return _shownEntries.get(translateEntryPosToIndex(position)); return _entryList.getShownEntries().get(_entryList.translateEntryPosToIndex(position));
} }
public int addEntry(VaultEntry entry) { public int getEntryPosition(VaultEntry entry) {
_entries.add(entry); return _entryList.translateEntryIndexToPos(_entryList.getShownEntries().indexOf(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);
}
}
_view.onListChange();
checkPeriodUniformity();
updateFooter();
return position;
} }
public void addEntries(Collection<VaultEntry> entries) { public void setEntries(List<VaultEntry> entries) {
for (VaultEntry entry: 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.setUsageCount(_usageCounts.containsKey(entry.getUUID()) ? _usageCounts.get(entry.getUUID()) : 0);
entry.setLastUsedTimestamp(_lastUsedTimestamps.containsKey(entry.getUUID()) ? _lastUsedTimestamps.get(entry.getUUID()) : 0); entry.setLastUsedTimestamp(_lastUsedTimestamps.containsKey(entry.getUUID()) ? _lastUsedTimestamps.get(entry.getUUID()) : 0);
} }
_entries.addAll(entries); replaceEntryList(new EntryList(
updateShownEntries(); entries,
checkPeriodUniformity(true); calculateShownEntries(entries),
} _entryList.getErrorCardInfo()
));
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);
} }
public void clearEntries() { public void clearEntries() {
_entries.clear(); replaceEntryList(new EntryList());
_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();
}
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) { public int translateEntryPosToIndex(int position) {
if (position == NO_POSITION) { return _entryList.translateEntryPosToIndex(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;
} }
private boolean isEntryFiltered(VaultEntry entry) { private boolean isEntryFiltered(VaultEntry entry) {
@ -353,7 +224,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public void refresh(boolean hard) { public void refresh(boolean hard) {
if (hard) { if (hard) {
updateShownEntries(); refreshEntryList();
} else { } else {
for (EntryHolder holder : _holders) { for (EntryHolder holder : _holders) {
holder.refresh(); holder.refresh();
@ -368,8 +239,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
} }
_groupFilter = groups; _groupFilter = groups;
updateShownEntries(); refreshEntryList();
checkPeriodUniformity();
} }
public void setSortCategory(SortCategory category, boolean apply) { public void setSortCategory(SortCategory category, boolean apply) {
@ -379,7 +249,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
_sortCategory = category; _sortCategory = category;
if (apply) { if (apply) {
updateShownEntries(); refreshEntryList();
} }
} }
@ -388,25 +258,59 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
} }
public void setSearchFilter(String search) { public void setSearchFilter(String search) {
_searchFilter = (search != null && !search.isEmpty()) ? search.toLowerCase().trim() : null; String newSearchFilter = (search != null && !search.isEmpty())
updateShownEntries(); ? search.toLowerCase().trim() : null;
if (!Objects.equals(_searchFilter, newSearchFilter)) {
_searchFilter = newSearchFilter;
refreshEntryList();
}
} }
private void updateShownEntries() { private void refreshEntryList() {
// clear the list of shown entries first replaceEntryList(new EntryList(
_shownEntries.clear(); _entryList.getEntries(),
calculateShownEntries(_entryList.getEntries()),
_entryList.getErrorCardInfo()
));
}
// add entries back that are not filtered out private void replaceEntryList(EntryList newEntryList) {
for (VaultEntry entry : _entries) { 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)) { if (!isEntryFiltered(entry)) {
_shownEntries.add(entry); res.add(entry);
} }
} }
sortShownEntries(); sortEntries(res, _sortCategory);
checkPeriodUniformity(); return res;
_view.onListChange(); }
notifyDataSetChanged();
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) { private boolean isEntryDraggable(VaultEntry entry) {
@ -417,18 +321,6 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
&& _selectedEntries.get(0) == entry; && _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) { public void setViewMode(ViewMode viewMode) {
_viewMode = viewMode; _viewMode = viewMode;
} }
@ -444,7 +336,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public Map<UUID, Long> getLastUsedTimestamps() { return _lastUsedTimestamps; } public Map<UUID, Long> getLastUsedTimestamps() { return _lastUsedTimestamps; }
public int getShownFavoritesCount() { public int getShownFavoritesCount() {
return (int) _shownEntries.stream().filter(VaultEntry::isFavorite).count(); return (int) _entryList.getShownEntries().stream().filter(VaultEntry::isFavorite).count();
} }
@Override @Override
@ -456,43 +348,48 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public void onItemDrop(int position) { public void onItemDrop(int position) {
// moving entries is not allowed when a filter is applied // moving entries is not allowed when a filter is applied
// footer cant be moved, nor can items be moved below it // 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; return;
} }
int index = translateEntryPosToIndex(position); int index = _entryList.translateEntryPosToIndex(position);
_view.onEntryDrop(_shownEntries.get(index)); _view.onEntryDrop(_entryList.getShownEntries().get(index));
} }
@Override @Override
public void onItemMove(int firstPosition, int secondPosition) { public void onItemMove(int firstPosition, int secondPosition) {
// moving entries is not allowed when a filter is applied // Moving entries is not allowed when a filter is applied. The footer can't be
// footer cant be moved, nor can items be moved below it // moved, nor can items be moved below it
if (!_groupFilter.isEmpty() if (!_groupFilter.isEmpty()
|| isPositionFooter(firstPosition) || isPositionFooter(secondPosition) || _entryList.isPositionFooter(firstPosition) || _entryList.isPositionFooter(secondPosition)
|| isPositionErrorCard(firstPosition) || isPositionErrorCard(secondPosition)) { || _entryList.isPositionErrorCard(firstPosition) || _entryList.isPositionErrorCard(secondPosition)) {
return; return;
} }
// notify the vault first // Notify the vault about the entry position change first
int firstIndex = translateEntryPosToIndex(firstPosition); int firstIndex = _entryList.translateEntryPosToIndex(firstPosition);
int secondIndex = translateEntryPosToIndex(secondPosition); int secondIndex = _entryList.translateEntryPosToIndex(secondPosition);
_view.onEntryMove(_entries.get(firstIndex), _entries.get(secondIndex)); VaultEntry firstEntry = _entryList.getShownEntries().get(firstIndex);
VaultEntry secondEntry = _entryList.getShownEntries().get(secondIndex);
_view.onEntryMove(firstEntry, secondEntry);
// then update our end // Then update the visual end
CollectionUtils.move(_entries, firstIndex, secondIndex); List<VaultEntry> newEntries = new ArrayList<>(_entryList.getEntries());
CollectionUtils.move(_shownEntries, firstIndex, secondIndex); CollectionUtils.move(newEntries, newEntries.indexOf(firstEntry), newEntries.indexOf(secondEntry));
replaceEntryList(new EntryList(
notifyItemMoved(firstPosition, secondPosition); newEntries,
calculateShownEntries(newEntries),
_entryList.getErrorCardInfo()
));
} }
@Override @Override
public int getItemViewType(int position) { public int getItemViewType(int position) {
if (isPositionErrorCard(position)) { if (_entryList.isPositionErrorCard(position)) {
return R.layout.card_error; return R.layout.card_error;
} }
if (isPositionFooter(position)) { if (_entryList.isPositionFooter(position)) {
return R.layout.card_footer; return R.layout.card_footer;
} }
@ -507,7 +404,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
View view = inflater.inflate(viewType, parent, false); View view = inflater.inflate(viewType, parent, false);
if (viewType == R.layout.card_error) { 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) { } else if (viewType == R.layout.card_footer) {
holder = new FooterView(view); holder = new FooterView(view);
} else { } else {
@ -533,8 +430,8 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) { public void onBindViewHolder(final RecyclerView.ViewHolder holder, int position) {
if (holder instanceof EntryHolder) { if (holder instanceof EntryHolder) {
EntryHolder entryHolder = (EntryHolder) holder; EntryHolder entryHolder = (EntryHolder) holder;
int index = translateEntryPosToIndex(position); int index = _entryList.translateEntryPosToIndex(position);
VaultEntry entry = _shownEntries.get(index); VaultEntry entry = _entryList.getShownEntries().get(index);
boolean hidden = _tapToReveal && entry != _focusedEntry; boolean hidden = _tapToReveal && entry != _focusedEntry;
boolean paused = _pauseFocused && entry == _focusedEntry; boolean paused = _pauseFocused && entry == _focusedEntry;
@ -543,7 +440,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
boolean showAccountName = true; boolean showAccountName = true;
if (_onlyShowNecessaryAccountNames) { if (_onlyShowNecessaryAccountNames) {
// Only show account name when there's multiple entries found with the same issuer. // 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())) .filter(x -> x.getIssuer().equals(entry.getIssuer()))
.count() > 1; .count() > 1;
} }
@ -621,8 +518,8 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
entryHolder.setFocusedAndAnimate(true); entryHolder.setFocusedAndAnimate(true);
} }
int index = translateEntryPosToIndex(position); int index = _entryList.translateEntryPosToIndex(position);
boolean returnVal = _view.onLongEntryClick(_shownEntries.get(index)); boolean returnVal = _view.onLongEntryClick(_entryList.getShownEntries().get(index));
if (_selectedEntries.size() == 0 || isEntryDraggable(entry)) { if (_selectedEntries.size() == 0 || isEntryDraggable(entry)) {
_view.startDrag(entryHolder); _view.startDrag(entryHolder);
} }
@ -668,15 +565,10 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
} }
} }
private void checkPeriodUniformity() { private void updatePeriodUniformity() {
checkPeriodUniformity(false);
}
private void checkPeriodUniformity(boolean force) {
int mostFrequentPeriod = getMostFrequentPeriod(); int mostFrequentPeriod = getMostFrequentPeriod();
boolean uniform = isPeriodUniform(); boolean uniform = isPeriodUniform();
if (uniform == _isPeriodUniform && mostFrequentPeriod == _uniformPeriod) {
if (!force && uniform == _isPeriodUniform && mostFrequentPeriod == _uniformPeriod) {
return; return;
} }
@ -694,7 +586,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public int getMostFrequentPeriod() { public int getMostFrequentPeriod() {
List<TotpInfo> infos = new ArrayList<>(); List<TotpInfo> infos = new ArrayList<>();
for (VaultEntry entry : _shownEntries) { for (VaultEntry entry : _entryList.getShownEntries()) {
OtpInfo info = entry.getInfo(); OtpInfo info = entry.getInfo();
if (info instanceof TotpInfo) { if (info instanceof TotpInfo) {
infos.add((TotpInfo) info); infos.add((TotpInfo) info);
@ -809,7 +701,7 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
public List<VaultEntry> selectAllEntries() { public List<VaultEntry> selectAllEntries() {
_selectedEntries.clear(); _selectedEntries.clear();
for (VaultEntry entry: _shownEntries) { for (VaultEntry entry: _entryList.getShownEntries()) {
for (EntryHolder holder: _holders) { for (EntryHolder holder: _holders) {
if (holder.getEntry() == entry) { if (holder.getEntry() == entry) {
holder.setFocused(true); holder.setFocused(true);
@ -863,34 +755,23 @@ public class EntryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
@Override @Override
public int getItemCount() { public int getItemCount() {
// Always at least one item because of the footer return _entryList.getItemCount();
// Two in case there's also an error card
int baseCount = 1;
if (isErrorCardShown()) {
baseCount++;
}
return baseCount + getShownEntriesCount();
} }
public int getShownEntriesCount() { public int getShownEntriesCount() {
return _shownEntries.size(); return _entryList.getShownEntries().size();
} }
public boolean isPositionFooter(int position) { public boolean isPositionFooter(int position) {
return position == (getItemCount() - 1); return _entryList.isPositionFooter(position);
} }
public boolean isPositionErrorCard(int position) { public boolean isPositionErrorCard(int position) {
return isErrorCardShown() && position == 0; return _entryList.isPositionErrorCard(position);
} }
public boolean isErrorCardShown() { public boolean isErrorCardShown() {
return _errorCardInfo != null; return _entryList.isErrorCardShown();
}
private void updateFooter() {
notifyItemChanged(getItemCount() - 1);
} }
private class FooterView extends RecyclerView.ViewHolder { private class FooterView extends RecyclerView.ViewHolder {
@ -917,6 +798,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 { public interface Listener {
void onEntryClick(VaultEntry entry); void onEntryClick(VaultEntry entry);
boolean onLongEntryClick(VaultEntry entry); boolean onLongEntryClick(VaultEntry entry);

View file

@ -50,6 +50,7 @@ import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.shape.ShapeAppearanceModel;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -156,6 +157,14 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
_preloadSizeProvider.setView(view); _preloadSizeProvider.setView(view);
} }
public int getScrollPosition() {
return ((LinearLayoutManager) _recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
}
public void scrollToPosition(int position) {
_recyclerView.getLayoutManager().scrollToPosition(position);
}
@Override @Override
public void onDestroyView() { public void onDestroyView() {
_refresher.destroy(); _refresher.destroy();
@ -167,14 +176,10 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
updateDividerDecoration(); updateDividerDecoration();
} }
public void setGroupFilter(Set<UUID> groups, boolean animate) { public void setGroupFilter(Set<UUID> groups) {
_adapter.setGroupFilter(groups); _adapter.setGroupFilter(groups);
_touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed()); _touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed());
updateEmptyState(); updateEmptyState();
if (animate) {
runEntriesAnimation();
}
} }
public void setIsLongPressDragEnabled(boolean enabled) { public void setIsLongPressDragEnabled(boolean enabled) {
@ -207,10 +212,6 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
public void setSortCategory(SortCategory sortCategory, boolean apply) { public void setSortCategory(SortCategory sortCategory, boolean apply) {
_adapter.setSortCategory(sortCategory, apply); _adapter.setSortCategory(sortCategory, apply);
_touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed()); _touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed());
if (apply) {
runEntriesAnimation();
}
} }
public void setUsageCounts(Map<UUID, Integer> usageCounts) { public void setUsageCounts(Map<UUID, Integer> usageCounts) {
@ -392,61 +393,57 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
_adapter.setErrorCardInfo(info); _adapter.setErrorCardInfo(info);
} }
public void addEntry(VaultEntry entry) {
addEntry(entry, false);
}
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
public void addEntry(VaultEntry entry, boolean focusEntry) { public void onEntryAdded(VaultEntry entry) {
int position = _adapter.addEntry(entry); int position = _adapter.getEntryPosition(entry);
updateEmptyState(); if (position < 0) {
return;
}
LinearLayoutManager layoutManager = (LinearLayoutManager) _recyclerView.getLayoutManager(); LinearLayoutManager layoutManager = (LinearLayoutManager) _recyclerView.getLayoutManager();
if (focusEntry && position >= 0) { if ((_recyclerView.canScrollVertically(1) && position > layoutManager.findLastCompletelyVisibleItemPosition())
if ((_recyclerView.canScrollVertically(1) && position > layoutManager.findLastCompletelyVisibleItemPosition()) || (_recyclerView.canScrollVertically(-1) && position < layoutManager.findFirstCompletelyVisibleItemPosition())) {
|| (_recyclerView.canScrollVertically(-1) && position < layoutManager.findFirstCompletelyVisibleItemPosition())) { boolean smoothScroll = !AnimationsHelper.Scale.TRANSITION.isZero(requireContext());
boolean smoothScroll = !AnimationsHelper.Scale.TRANSITION.isZero(requireContext()); RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() { private void handleScroll() {
private void handleScroll() { _recyclerView.removeOnScrollListener(this);
_recyclerView.removeOnScrollListener(this); _recyclerView.setOnTouchListener(null);
_recyclerView.setOnTouchListener(null); tempHighlightEntry(entry);
tempHighlightEntry(entry);
}
@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();
}
}
};
_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);
} }
@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();
}
}
};
_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 { } else {
tempHighlightEntry(entry); _recyclerView.scrollToPosition(position);
} }
} else {
tempHighlightEntry(entry);
} }
} }
@ -457,27 +454,14 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
_adapter.focusEntry(entry, secondsToFocus); _adapter.focusEntry(entry, secondsToFocus);
} }
public void addEntries(Collection<VaultEntry> entries) { public void setEntries(Collection<VaultEntry> entries) {
_adapter.addEntries(entries); _adapter.setEntries(new ArrayList<>(entries));
updateEmptyState();
}
public void removeEntry(VaultEntry entry) {
_adapter.removeEntry(entry);
updateEmptyState();
}
public void removeEntry(UUID uuid) {
_adapter.removeEntry(uuid);
updateEmptyState(); updateEmptyState();
} }
public void clearEntries() { public void clearEntries() {
_adapter.clearEntries(); _adapter.clearEntries();
} updateEmptyState();
public void replaceEntry(UUID uuid, VaultEntry newEntry) {
_adapter.replaceEntry(uuid, newEntry);
} }
public void runEntriesAnimation() { public void runEntriesAnimation() {
@ -576,7 +560,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
// Only non-favorite entries have a bottom margin, except for the final favorite entry // Only non-favorite entries have a bottom margin, except for the final favorite entry
int totalFavorites = _adapter.getShownFavoritesCount(); int totalFavorites = _adapter.getShownFavoritesCount();
if (totalFavorites == 0 if (totalFavorites == 0
|| (entryIndex < _adapter.getShownEntriesCount() && !_adapter.getEntryAtPos(adapterPosition).isFavorite()) || (entryIndex < _adapter.getShownEntriesCount() && !_adapter.getEntryAtPosition(adapterPosition).isFavorite())
|| totalFavorites == entryIndex + 1) { || totalFavorites == entryIndex + 1) {
outRect.bottom = _offset; outRect.bottom = _offset;
} }
@ -669,7 +653,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
return Collections.emptyList(); return Collections.emptyList();
} }
VaultEntry entry = _adapter.getEntryAtPos(position); VaultEntry entry = _adapter.getEntryAtPosition(position);
if (!entry.hasIcon()) { if (!entry.hasIcon()) {
return Collections.emptyList(); return Collections.emptyList();
} }

View file

@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
import androidx.core.util.AtomicFile; import androidx.core.util.AtomicFile;
import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.GoogleAuthInfo;
import com.beemdevelopment.aegis.util.Cloner;
import com.beemdevelopment.aegis.util.IOUtils; import com.beemdevelopment.aegis.util.IOUtils;
import com.google.zxing.WriterException; import com.google.zxing.WriterException;
@ -249,6 +250,13 @@ public class VaultRepository {
return _vault.getEntries().replace(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. * Moves entry1 to the position of entry2.
*/ */
@ -344,4 +352,8 @@ public class VaultRepository {
return getCredentials().getSlots().findBackupPasswordSlots().size() > 0; return getCredentials().getSlots().findBackupPasswordSlots().size() > 0;
} }
public interface EntryEditor {
void edit(VaultEntry entry);
}
} }