Merge pull request #1479 from michaelschattgen/feature/group-chipgroup

Improve group filters
This commit is contained in:
Alexander Bakker 2024-09-16 23:47:07 +02:00 committed by GitHub
commit 8b8e071831
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 215 additions and 189 deletions

View file

@ -177,12 +177,10 @@ public class OverallTest extends AegisTest {
}
private void changeGroupFilter(String text) {
onView(withId(R.id.chip_group)).perform(click());
if (text == null) {
onView(withId(R.id.btnClear)).perform(click());
onView(allOf(withText(R.string.all), isDescendantOfA(withId(R.id.groupChipGroup)))).perform(click());
} else {
onView(withText(text)).perform(click());
onView(isRoot()).perform(pressBack());
onView(allOf(withText(text), isDescendantOfA(withId(R.id.groupChipGroup)))).perform(click());
}
}

View file

@ -32,6 +32,7 @@ import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.view.ActionMode;
import androidx.appcompat.widget.SearchView;
@ -50,21 +51,29 @@ import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
import com.beemdevelopment.aegis.ui.fragments.preferences.BackupsPreferencesFragment;
import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment;
import com.beemdevelopment.aegis.ui.models.ErrorCardInfo;
import com.beemdevelopment.aegis.ui.models.VaultGroupModel;
import com.beemdevelopment.aegis.ui.tasks.QrDecodeTask;
import com.beemdevelopment.aegis.ui.views.EntryListView;
import com.beemdevelopment.aegis.util.TimeUtils;
import com.beemdevelopment.aegis.util.UUIDMap;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultFile;
import com.beemdevelopment.aegis.vault.VaultGroup;
import com.beemdevelopment.aegis.vault.VaultRepository;
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.common.base.Strings;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -91,6 +100,11 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
private SearchView _searchView;
private EntryListView _entryListView;
private Collection<VaultGroup> _groups;
private ChipGroup _groupChip;
private Set<UUID> _groupFilter;
private Set<UUID> _prefGroupFilter;
private FabScrollHelper _fabScrollHelper;
private ActionMode _actionMode;
@ -196,7 +210,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
_entryListView.setViewMode(_prefs.getCurrentViewMode());
_entryListView.setCopyBehavior(_prefs.getCopyBehavior());
_entryListView.setSearchBehaviorMask(_prefs.getSearchBehaviorMask());
_entryListView.setPrefGroupFilter(_prefs.getGroupFilter());
_prefGroupFilter = _prefs.getGroupFilter();
FloatingActionButton fab = findViewById(R.id.fab);
fab.setOnClickListener(v -> {
@ -220,10 +234,150 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
Dialogs.showSecureDialog(dialog);
});
_groupChip = findViewById(R.id.groupChipGroup);
_fabScrollHelper = new FabScrollHelper(fab);
_selectedEntries = new ArrayList<>();
}
public void setGroups(Collection<VaultGroup> groups) {
_groups = groups;
_groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE);
if (_prefGroupFilter != null) {
Set<UUID> groupFilter = cleanGroupFilter(_prefGroupFilter);
_prefGroupFilter = null;
if (!groupFilter.isEmpty()) {
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, false);
}
} else if (_groupFilter != null) {
Set<UUID> groupFilter = cleanGroupFilter(_groupFilter);
if (!_groupFilter.equals(groupFilter)) {
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, true);
}
}
_entryListView.setGroups(groups);
initializeGroups();
}
private void initializeGroups() {
_groupChip.removeAllViews();
addChipTo(_groupChip, new VaultGroupModel(getString(R.string.all)));
for (VaultGroup group : _groups) {
addChipTo(_groupChip, new VaultGroupModel(group));
}
addSaveChip(_groupChip);
}
private Set<UUID> cleanGroupFilter(Set<UUID> groupFilter) {
Set<UUID> groupUuids = _groups.stream().map(UUIDMap.Value::getUUID).collect(Collectors.toSet());
return groupFilter.stream()
.filter(g -> g == null || groupUuids.contains(g))
.collect(Collectors.toSet());
}
private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) {
Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false);
chip.setText(group.getName());
chip.setCheckable(true);
chip.setCheckedIconVisible(false);
chip.setChecked(_groupFilter != null && _groupFilter.contains(group.getUUID()));
if (group.isPlaceholder()) {
chip.setId(0);
chip.setTag(null);
chip.setChecked(_groupFilter == null);
chip.setOnClickListener(v -> {
setSaveChipVisibility(true);
Chip checkedChip = (Chip) chipGroup.getChildAt(0);
boolean checkedState = checkedChip.isChecked();
chipGroup.clearCheck();
Set<UUID> groupFilter = getGroupFilter(chipGroup);
if (!checkedState) {
groupFilter = new HashSet<>();
groupFilter.add(null);
checkedChip.setChecked(false);
} else {
checkedChip.setChecked(true);
}
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, true);
});
chipGroup.addView(chip);
return;
}
chip.setOnCheckedChangeListener((group1, checkedId) -> {
setSaveChipVisibility(true);
Set<UUID> groupFilter = getGroupFilter(chipGroup);
if (groupFilter.isEmpty()) {
groupFilter.add(null);
} else {
Chip allGroupsChip = (Chip) chipGroup.getChildAt(0);
allGroupsChip.setChecked(false);
}
_groupFilter = groupFilter;
_entryListView.setGroupFilter(groupFilter, true);
});
chip.setTag(group);
chipGroup.addView(chip);
}
private void addSaveChip(ChipGroup chipGroup) {
Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false);
chip.setText(getString(R.string.save));
chip.setVisibility(View.GONE);
chip.setChipStrokeWidth(0);
chip.setCheckable(false);
chip.setChipBackgroundColorResource(android.R.color.transparent);
chip.setTextColor(MaterialColors.getColor(chip.getRootView(), com.google.android.material.R.attr.colorSecondary));
chip.setClickable(true);
chip.setCheckedIconVisible(false);
chip.setOnClickListener(v -> {
onSaveGroupFilter(_groupFilter);
setSaveChipVisibility(false);
});
chipGroup.addView(chip);
}
private void setSaveChipVisibility(boolean visible) {
Chip saveChip = (Chip) _groupChip.getChildAt(_groupChip.getChildCount() - 1);
saveChip.setChecked(false);
saveChip.setVisibility(visible ? View.VISIBLE : View.GONE);
}
private static Set<UUID> getGroupFilter(ChipGroup chipGroup) {
return chipGroup.getCheckedChipIds().stream()
.map(i -> {
Chip chip = chipGroup.findViewById(i);
if (chip.getTag() != null) {
VaultGroupModel group = (VaultGroupModel) chip.getTag();
return group.getUUID();
}
return null;
})
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
@Override
protected void onDestroy() {
_entryListView.setListener(null);
@ -252,6 +406,10 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
instance.putString("submittedSearchQuery", _submittedSearchQuery);
instance.putBoolean("isDoingIntro", _isDoingIntro);
instance.putBoolean("isAuthenticating", _isAuthenticating);
if (_groupFilter != null) {
instance.putSerializable("prefGroupFilter", new HashSet<>(_groupFilter));
}
}
@Override
@ -677,7 +835,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
startAuthActivity(false);
} else if (_loaded) {
// update the list of groups in the entry list view so that the chip gets updated
_entryListView.setGroups(_vaultManager.getVault().getUsedGroups());
setGroups(_vaultManager.getVault().getUsedGroups());
// update the usage counts in case they are edited outside of the EntryListView
_entryListView.setUsageCounts(_prefs.getUsageCounts());
@ -717,7 +875,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
updateLockIcon();
if (_loaded) {
_entryListView.setGroups(_vaultManager.getVault().getUsedGroups());
setGroups(_vaultManager.getVault().getUsedGroups());
updateSortCategoryMenu();
}
@ -836,7 +994,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
private void loadEntries() {
if (!_loaded) {
_entryListView.setGroups(_vaultManager.getVault().getUsedGroups());
setGroups(_vaultManager.getVault().getUsedGroups());
_entryListView.setUsageCounts(_prefs.getUsageCounts());
_entryListView.setLastUsedTimestamps(_prefs.getLastUsedTimestamps());
_entryListView.addEntries(_vaultManager.getVault().getEntries());
@ -930,6 +1088,19 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
Dialogs.showSecureDialog(dialog);
}
@Override
public void onRestoreInstanceState(@Nullable Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
if (savedInstanceState == null) {
return;
}
HashSet<UUID> filter = (HashSet<UUID>) savedInstanceState.getSerializable("prefGroupFilter");
if (filter != null) {
_prefGroupFilter = filter;
}
}
@Override
public void onEntryClick(VaultEntry entry) {
if (_actionMode != null) {

View file

@ -13,15 +13,12 @@ import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.LayoutAnimationController;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.widget.NestedScrollView;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.ItemTouchHelper;
@ -39,10 +36,8 @@ import com.beemdevelopment.aegis.helpers.MetricsHelper;
import com.beemdevelopment.aegis.helpers.SimpleItemTouchHelperCallback;
import com.beemdevelopment.aegis.helpers.UiRefresher;
import com.beemdevelopment.aegis.otp.TotpInfo;
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
import com.beemdevelopment.aegis.ui.glide.GlideHelper;
import com.beemdevelopment.aegis.ui.models.ErrorCardInfo;
import com.beemdevelopment.aegis.ui.models.VaultGroupModel;
import com.beemdevelopment.aegis.util.UUIDMap;
import com.beemdevelopment.aegis.vault.VaultEntry;
import com.beemdevelopment.aegis.vault.VaultGroup;
@ -51,11 +46,7 @@ import com.bumptech.glide.ListPreloader;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader;
import com.bumptech.glide.util.ViewPreloadSizeProvider;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import com.google.android.material.card.MaterialCardView;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel;
import com.google.common.base.Strings;
@ -81,11 +72,7 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
private TotpProgressBar _progressBar;
private boolean _showProgress;
private ViewMode _viewMode;
private Collection<VaultGroup> _groups;
private LinearLayout _emptyStateView;
private Chip _groupChip;
private Set<UUID> _groupFilter;
private Set<UUID> _prefGroupFilter;
private UiRefresher _refresher;
@ -106,8 +93,6 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_entry_list_view, container, false);
_progressBar = view.findViewById(R.id.progressBar);
_groupChip = view.findViewById(R.id.chip_group);
initializeGroupChip();
// set up the recycler view
_recyclerView = view.findViewById(R.id.rvKeyProfiles);
@ -173,41 +158,21 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
_preloadSizeProvider.setView(view);
}
@Override
public void onViewStateRestored(@Nullable Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
if (savedInstanceState == null) {
return;
}
HashSet<UUID> filter = (HashSet<UUID>) savedInstanceState.getSerializable("prefGroupFilter");
if (filter != null) {
_prefGroupFilter = filter;
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
// user can apply _groupFilter without saving
// restore _groupFilter as _prefGroupFilter in order to reapply correct filter after screen rotate
if (_groupFilter != null) {
outState.putSerializable("prefGroupFilter", new HashSet<>(_groupFilter));
}
}
@Override
public void onDestroyView() {
_refresher.destroy();
super.onDestroyView();
}
public void setGroups(Collection<VaultGroup> groups) {
_adapter.setGroups(groups);
updateDividerDecoration();
}
public void setGroupFilter(Set<UUID> groups, boolean animate) {
_groupFilter = groups;
_adapter.setGroupFilter(groups);
_touchCallback.setIsLongPressDragEnabled(_adapter.isDragAndDropAllowed());
updateEmptyState();
updateGroupChip();
if (animate) {
runEntriesAnimation();
@ -384,10 +349,6 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
}
}
public void setPrefGroupFilter(Set<UUID> groupFilter) {
_prefGroupFilter = groupFilter;
}
public void setCodeGroupSize(Preferences.CodeGrouping codeGrouping) {
_adapter.setCodeGroupSize(codeGrouping);
}
@ -519,116 +480,11 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
_recyclerView.scheduleLayoutAnimation();
}
private void addChipTo(ChipGroup chipGroup, VaultGroupModel group) {
Chip chip = (Chip) getLayoutInflater().inflate(R.layout.chip_group_filter, null, false);
chip.setText(group.getName());
chip.setCheckable(true);
chip.setChecked(_groupFilter != null && _groupFilter.contains(group.getUUID()));
chip.setCheckedIconVisible(true);
chip.setOnCheckedChangeListener((group1, checkedId) -> {
Set<UUID> groupFilter = getGroupFilter(chipGroup);
setGroupFilter(groupFilter, true);
});
chip.setTag(group);
chipGroup.addView(chip);
}
private void initializeGroupChip() {
View view = getLayoutInflater().inflate(R.layout.dialog_select_groups, null);
BottomSheetDialog dialog = new BottomSheetDialog(requireContext());
NestedScrollView scrollView = view.findViewById(R.id.scrollView);
ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) scrollView.getLayoutParams();
layoutParams.matchConstraintMaxHeight = getResources().getConfiguration().screenHeightDp;
dialog.getBehavior().setState(BottomSheetBehavior.STATE_EXPANDED);
dialog.getBehavior().setSkipCollapsed(false);
dialog.setContentView(view);
ChipGroup chipGroup = view.findViewById(R.id.groupChipGroup);
Button clearButton = view.findViewById(R.id.btnClear);
Button saveButton = view.findViewById(R.id.btnSave);
clearButton.setOnClickListener(v -> {
chipGroup.clearCheck();
Set<UUID> groupFilter = Collections.emptySet();
if (_listener != null) {
_listener.onSaveGroupFilter(groupFilter);
}
setGroupFilter(groupFilter, true);
dialog.dismiss();
});
saveButton.setOnClickListener(v -> {
Set<UUID> groupFilter = getGroupFilter(chipGroup);
if (_listener != null) {
_listener.onSaveGroupFilter(groupFilter);
}
setGroupFilter(groupFilter, true);
dialog.dismiss();
});
_groupChip.setOnClickListener(v -> {
chipGroup.removeAllViews();
for (VaultGroup group : _groups) {
addChipTo(chipGroup, new VaultGroupModel(group));
}
addChipTo(chipGroup, new VaultGroupModel(getString(R.string.no_group)));
Dialogs.showSecureDialog(dialog);
});
}
private static Set<UUID> getGroupFilter(ChipGroup chipGroup) {
return chipGroup.getCheckedChipIds().stream()
.map(i -> {
Chip chip = chipGroup.findViewById(i);
VaultGroupModel group = (VaultGroupModel) chip.getTag();
return group.getUUID();
})
.collect(Collectors.toSet());
}
private void updateGroupChip() {
if (_groupFilter.isEmpty()) {
_groupChip.setText(R.string.groups);
} else {
_groupChip.setText(String.format("%s (%d)", getString(R.string.groups), _groupFilter.size()));
}
}
private void setShowProgress(boolean showProgress) {
_showProgress = showProgress;
updateDividerDecoration();
}
public void setGroups(Collection<VaultGroup> groups) {
_groups = groups;
_adapter.setGroups(groups);
_groupChip.setVisibility(_groups.isEmpty() ? View.GONE : View.VISIBLE);
updateDividerDecoration();
if (_prefGroupFilter != null) {
Set<UUID> groupFilter = cleanGroupFilter(_prefGroupFilter);
_prefGroupFilter = null;
if (!groupFilter.isEmpty()) {
setGroupFilter(groupFilter, false);
}
} else if (_groupFilter != null) {
Set<UUID> groupFilter = cleanGroupFilter(_groupFilter);
if (!_groupFilter.equals(groupFilter)) {
setGroupFilter(groupFilter, true);
}
}
}
private Set<UUID> cleanGroupFilter(Set<UUID> groupFilter) {
Set<UUID> groupUuids = _groups.stream().map(UUIDMap.Value::getUUID).collect(Collectors.toSet());
return groupFilter.stream()
.filter(g -> g == null || groupUuids.contains(g))
.collect(Collectors.toSet());
}
private void updateDividerDecoration() {
if (_itemDecoration != null) {
_recyclerView.removeItemDecoration(_itemDecoration);
@ -705,8 +561,8 @@ public class EntryListView extends Fragment implements EntryAdapter.Listener {
}
int entryIndex = _adapter.translateEntryPosToIndex(adapterPosition);
// The first entry should have a top margin, but only if the group chip is not shown and the error card is not shown
if (entryIndex == 0 && (_groups == null || _groups.isEmpty()) && !_adapter.isErrorCardShown()) {
// The first entry should have a top margin, but only if the error card is not shown
if (entryIndex == 0 && !_adapter.isErrorCardShown()) {
outRect.top = _offset;
}

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
@ -15,30 +14,47 @@
android:fitsSystemWindows="true"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@+id/rvKeyProfiles">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="0dp"
android:paddingTop="0dp"
android:paddingBottom="0dp"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<com.google.android.material.chip.ChipGroup
android:id="@+id/groupChipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"></com.google.android.material.chip.ChipGroup>
</LinearLayout>
</HorizontalScrollView>
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:orientation="vertical">
<com.google.android.material.chip.ChipGroup
android:id="@+id/groupChipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</com.google.android.material.chip.ChipGroup>
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<fragment
android:name="com.beemdevelopment.aegis.ui.views.EntryListView"
android:id="@+id/key_profiles"
android:layout_height="fill_parent"
android:name="com.beemdevelopment.aegis.ui.views.EntryListView"
android:layout_width="match_parent"
android:layout_height="fill_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</LinearLayout>
@ -48,6 +64,6 @@
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:src="@drawable/ic_outline_add_24" />
android:src="@drawable/ic_outline_add_24" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -86,7 +86,6 @@
<com.google.android.material.chip.ChipGroup
android:id="@+id/groupChipGroup"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</com.google.android.material.chip.ChipGroup>

View file

@ -14,21 +14,6 @@
android:visibility="gone"
android:max="5000"/>
<com.google.android.material.chip.Chip
android:id="@+id/chip_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/groups"
android:layout_gravity="end"
android:layout_marginEnd="8dp"
android:visibility="gone"
app:chipEndPadding="6dp"
app:chipIcon="@drawable/ic_outline_filter_list_24"
app:chipIconTint="?attr/colorOnSurface"
app:chipIconSize="18dp"
app:chipStrokeWidth="1dp"
app:iconStartPadding="6dp" />
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="0dp"

View file

@ -12,6 +12,7 @@
<string name="action_default_icon">Restore default icon</string>
<string name="discard">Discard</string>
<string name="save">Save</string>
<string name="all">All</string>
<string name="issuer">Issuer</string>
<string name="yandex_pin">PIN (416 digits)</string>
<string name="motp_pin">PIN (4 digits)</string>