From 3319af9ca9727300526a07895bfd2431290cbcf6 Mon Sep 17 00:00:00 2001 From: Paolo Monni Date: Tue, 14 Jul 2026 15:04:46 +0200 Subject: [PATCH] feat(navigation): support arbitrary-depth category tree in drawer Notes organized in nested categories can now be expanded to any depth in the navigation drawer instead of being capped at two levels. Refs #1630. AI-assistant: Claude Code (Claude Opus 4.8) Signed-off-by: Paolo Monni --- .../owncloud/notes/main/MainActivity.java | 19 +- .../owncloud/notes/main/MainViewModel.java | 123 +++---------- .../main/navigation/CategoryExpandState.java | 17 ++ .../main/navigation/CategoryTreeMapper.java | 120 +++++++++++++ .../main/navigation/CategoryTreeNode.java | 45 +++++ .../notes/main/navigation/NavigationItem.java | 3 + .../main/navigation/NavigationViewHolder.java | 4 +- .../notes/shared/util/DisplayUtils.java | 20 ++- .../notes/main/MainViewModelTest.java | 112 ------------ .../navigation/CategoryTreeMapperTest.java | 165 ++++++++++++++++++ 10 files changed, 402 insertions(+), 226 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryExpandState.java create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapper.java create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeNode.java create mode 100644 app/src/test/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapperTest.java diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java index ed7b84ca6..8102d11df 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -106,6 +106,7 @@ import it.niedermann.owncloud.notes.main.items.section.SectionItemDecoration; import it.niedermann.owncloud.notes.main.items.selection.ItemSelectionTracker; import it.niedermann.owncloud.notes.main.menu.MenuAdapter; +import it.niedermann.owncloud.notes.main.navigation.CategoryExpandState; import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; import it.niedermann.owncloud.notes.main.navigation.NavigationClickListener; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; @@ -690,18 +691,12 @@ private void selectItem(NavigationItem item, boolean closeNavigation) { @Override public void onIconClick(NavigationItem item) { - final var expandedCategoryLiveData = mainViewModel.getExpandedCategory(); - expandedCategoryLiveData.observe(MainActivity.this, expandedCategory -> { - if (item.icon == NavigationAdapter.ICON_MULTIPLE && !item.label.equals(expandedCategory)) { - mainViewModel.postExpandedCategory(item.label); - selectItem(item, false); - } else if (item.icon == NavigationAdapter.ICON_MULTIPLE || item.icon == NavigationAdapter.ICON_MULTIPLE_OPEN && item.label.equals(expandedCategory)) { - mainViewModel.postExpandedCategory(null); - } else { - onItemClick(item); - } - expandedCategoryLiveData.removeObservers(MainActivity.this); - }); + if (item.expandState != CategoryExpandState.NOT_EXPANDABLE + && item instanceof NavigationItem.CategoryNavigationItem categoryItem) { + mainViewModel.toggleExpandedCategory(categoryItem.category); + } else { + onItemClick(item); + } } }); adapterCategories.setSelectedItem(ADAPTER_KEY_RECENT); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java index 771cb42a4..6e6f07a36 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java @@ -10,8 +10,6 @@ import static androidx.lifecycle.Transformations.map; import static androidx.lifecycle.Transformations.switchMap; import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; -import static it.niedermann.owncloud.notes.main.MainActivity.ADAPTER_KEY_RECENT; -import static it.niedermann.owncloud.notes.main.MainActivity.ADAPTER_KEY_STARRED; import static it.niedermann.owncloud.notes.main.slots.SlotterUtil.fillListByCategory; import static it.niedermann.owncloud.notes.main.slots.SlotterUtil.fillListByInitials; import static it.niedermann.owncloud.notes.main.slots.SlotterUtil.fillListByTime; @@ -21,7 +19,6 @@ import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; -import static it.niedermann.owncloud.notes.shared.util.DisplayUtils.convertToCategoryNavigationItem; import android.accounts.NetworkErrorException; import android.app.Application; @@ -49,23 +46,23 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; import it.niedermann.owncloud.notes.BuildConfig; -import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.exception.IntendedOfflineException; -import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter; +import it.niedermann.owncloud.notes.main.navigation.CategoryTreeMapper; import it.niedermann.owncloud.notes.main.navigation.NavigationItem; import it.niedermann.owncloud.notes.persistence.ApiProvider; import it.niedermann.owncloud.notes.persistence.CapabilitiesClient; import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; -import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; import it.niedermann.owncloud.notes.shared.model.Capabilities; @@ -98,7 +95,7 @@ public class MainViewModel extends AndroidViewModel { @NonNull private final MutableLiveData selectedCategory = new MutableLiveData<>(new NavigationCategory(RECENT)); @NonNull - private final MutableLiveData expandedCategory = new MutableLiveData<>(null); + private final MutableLiveData> expandedCategories = new MutableLiveData<>(Collections.emptySet()); public MainViewModel(@NonNull Application application, @NonNull SavedStateHandle savedStateHandle) { super(application); @@ -118,7 +115,10 @@ public void restoreInstanceState() { postSelectedCategory(selectedCategory); Log.v(TAG, "[restoreInstanceState] - selectedCategory: " + selectedCategory); } - postExpandedCategory(state.get(KEY_EXPANDED_CATEGORY)); + final ArrayList restoredExpanded = state.get(KEY_EXPANDED_CATEGORY); + if (restoredExpanded != null) { + postExpandedCategories(new HashSet<>(restoredExpanded)); + } } @NonNull @@ -165,23 +165,13 @@ public void postSelectedCategory(@NonNull NavigationCategory selectedCategory) { Log.v(TAG, "[postSelectedCategory] - selectedCategory: " + selectedCategory); this.selectedCategory.postValue(selectedCategory); - // Close sub categories + // Collapse the whole tree when leaving the category navigation switch (selectedCategory.getType()) { - case RECENT, FAVORITES, UNCATEGORIZED -> { - postExpandedCategory(null); - } + case RECENT, FAVORITES, UNCATEGORIZED -> postExpandedCategories(Collections.emptySet()); default -> { - final String category = selectedCategory.getCategory(); - if (category == null) { - postExpandedCategory(null); + if (selectedCategory.getCategory() == null) { + postExpandedCategories(Collections.emptySet()); Log.e(TAG, "navigation selection is a " + DEFAULT_CATEGORY + ", but the contained category is null."); - } else { - int slashIndex = category.indexOf('/'); - final String rootCategory = slashIndex < 0 ? category : category.substring(0, slashIndex); - final String expandedCategory = getExpandedCategory().getValue(); - if (expandedCategory != null && !expandedCategory.equals(rootCategory)) { - postExpandedCategory(null); - } } } } @@ -205,14 +195,23 @@ public LiveData modifyCategoryOrder(@NonNull NavigationCategory selectedCa }); } - public void postExpandedCategory(@Nullable String expandedCategory) { - state.set(KEY_EXPANDED_CATEGORY, expandedCategory); - this.expandedCategory.postValue(expandedCategory); + public void postExpandedCategories(@NonNull Set expandedCategories) { + state.set(KEY_EXPANDED_CATEGORY, new ArrayList<>(expandedCategories)); + this.expandedCategories.postValue(expandedCategories); + } + + public void toggleExpandedCategory(@NonNull String category) { + final var current = expandedCategories.getValue(); + final var updated = current == null ? new HashSet() : new HashSet<>(current); + if (!updated.remove(category)) { + updated.add(category); + } + postExpandedCategories(updated); } @NonNull - public LiveData getExpandedCategory() { - return distinctUntilChanged(expandedCategory); + public LiveData> getExpandedCategories() { + return distinctUntilChanged(expandedCategories); } @NonNull @@ -304,14 +303,14 @@ public LiveData> getNavigationCategories() { return insufficientInformation; } else { Log.v(TAG, "[getNavigationCategories] - currentAccount: " + currentAccount.getAccountName()); - return switchMap(getExpandedCategory(), expandedCategory -> { - Log.v(TAG, "[getNavigationCategories] - expandedCategory: " + expandedCategory); + return switchMap(getExpandedCategories(), expandedCategories -> { + Log.v(TAG, "[getNavigationCategories] - expandedCategories: " + expandedCategories); return switchMap(repo.count$(currentAccount.getId()), (count) -> { Log.v(TAG, "[getNavigationCategories] - count: " + count); return switchMap(repo.countFavorites$(currentAccount.getId()), (favoritesCount) -> { Log.v(TAG, "[getNavigationCategories] - favoritesCount: " + favoritesCount); return distinctUntilChanged(map(repo.getCategories$(currentAccount.getId()), fromDatabase -> - fromCategoriesWithNotesCount(getApplication(), expandedCategory, fromDatabase, count, favoritesCount) + CategoryTreeMapper.map(getApplication(), expandedCategories, fromDatabase, count, favoritesCount) )); }); }); @@ -320,70 +319,6 @@ public LiveData> getNavigationCategories() { }); } - private static List fromCategoriesWithNotesCount(@NonNull Context context, @Nullable String expandedCategory, @NonNull List fromDatabase, int count, int favoritesCount) { - final var categories = convertToCategoryNavigationItem(context, fromDatabase); - final var itemRecent = new NavigationItem(ADAPTER_KEY_RECENT, context.getString(R.string.label_all_notes), count, R.drawable.selector_all_notes, RECENT); - final var itemFavorites = new NavigationItem(ADAPTER_KEY_STARRED, context.getString(R.string.label_favorites), favoritesCount, R.drawable.selector_favorites, FAVORITES); - - final var items = new ArrayList(fromDatabase.size() + 3); - items.add(itemRecent); - items.add(itemFavorites); - NavigationItem lastPrimaryCategory = null; - NavigationItem lastSecondaryCategory = null; - for (final var item : categories) { - final int slashIndex = item.label.indexOf('/'); - final String currentPrimaryCategory = slashIndex < 0 ? item.label : item.label.substring(0, slashIndex); - final boolean isCategoryOpen = currentPrimaryCategory.equals(expandedCategory); - String currentSecondaryCategory = null; - - if (isCategoryOpen && !currentPrimaryCategory.equals(item.label)) { - final String currentCategorySuffix = item.label.substring(expandedCategory.length() + 1); - final int subSlashIndex = currentCategorySuffix.indexOf('/'); - currentSecondaryCategory = subSlashIndex < 0 ? currentCategorySuffix : currentCategorySuffix.substring(0, subSlashIndex); - } - - boolean belongsToLastPrimaryCategory = lastPrimaryCategory != null && currentPrimaryCategory.equals(lastPrimaryCategory.label); - final boolean belongsToLastSecondaryCategory = belongsToLastPrimaryCategory && lastSecondaryCategory != null && lastSecondaryCategory.label.equals(currentSecondaryCategory); - - if (isCategoryOpen && !belongsToLastPrimaryCategory && currentSecondaryCategory != null) { - lastPrimaryCategory = new NavigationItem("category:" + currentPrimaryCategory, currentPrimaryCategory, 0, NavigationAdapter.ICON_MULTIPLE_OPEN); - items.add(lastPrimaryCategory); - belongsToLastPrimaryCategory = true; - } - - if (belongsToLastPrimaryCategory && belongsToLastSecondaryCategory) { - lastSecondaryCategory.count += item.count; - lastSecondaryCategory.icon = NavigationAdapter.ICON_SUB_MULTIPLE; - } else if (belongsToLastPrimaryCategory) { - if (isCategoryOpen) { - if (currentSecondaryCategory == null) { - throw new IllegalStateException("Current secondary category is null. Last primary category: " + lastPrimaryCategory); - } - item.label = currentSecondaryCategory; - item.id = "category:" + item.label; - item.icon = NavigationAdapter.ICON_SUB_FOLDER; - items.add(item); - lastSecondaryCategory = item; - } else { - lastPrimaryCategory.count += item.count; - lastPrimaryCategory.icon = NavigationAdapter.ICON_MULTIPLE; - lastSecondaryCategory = null; - } - } else { - if (isCategoryOpen) { - item.icon = NavigationAdapter.ICON_MULTIPLE_OPEN; - } else { - item.label = currentPrimaryCategory; - item.id = "category:" + item.label; - } - items.add(item); - lastPrimaryCategory = item; - lastSecondaryCategory = null; - } - } - return items; - } - public void synchronizeCapabilitiesAndNotes(Context context, @NonNull Account localAccount, @NonNull IResponseCallback callback) { Log.i(TAG, "[synchronizeCapabilitiesAndNotes] Synchronize capabilities for " + localAccount.getAccountName()); synchronizeCapabilities(localAccount, new IResponseCallback<>() { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryExpandState.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryExpandState.java new file mode 100644 index 000000000..d9f25b496 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryExpandState.java @@ -0,0 +1,17 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.main.navigation; + +/** + * Describes whether a navigation item representing a category can be expanded to reveal its + * sub categories, and if so whether it is currently expanded. + */ +public enum CategoryExpandState { + NOT_EXPANDABLE, + COLLAPSED, + EXPANDED +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapper.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapper.java new file mode 100644 index 000000000..31341ecc0 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapper.java @@ -0,0 +1,120 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.main.navigation; + +import static it.niedermann.owncloud.notes.main.MainActivity.ADAPTER_KEY_RECENT; +import static it.niedermann.owncloud.notes.main.MainActivity.ADAPTER_KEY_STARRED; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; +import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; + +import android.content.Context; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import it.niedermann.owncloud.notes.R; +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.shared.util.DisplayUtils; + +/** + * Turns the flat list of categories into the hierarchical list of {@link NavigationItem}s shown in + * the navigation drawer. Categories are organized as a tree along their {@code /} separator and can + * be expanded to an arbitrary depth: a sub category is only listed when every one of its ancestors + * is contained in {@code expandedCategories}. + */ +public final class CategoryTreeMapper { + + private static final String CATEGORY_SEPARATOR = "/"; + + private CategoryTreeMapper() { + throw new UnsupportedOperationException("Do not instantiate this util class."); + } + + @NonNull + public static List map(@NonNull Context context, + @NonNull Set expandedCategories, + @NonNull List categories, + int count, + int favoritesCount) { + final var items = new ArrayList(categories.size() + 2); + items.add(new NavigationItem(ADAPTER_KEY_RECENT, context.getString(R.string.label_all_notes), count, R.drawable.selector_all_notes, RECENT)); + items.add(new NavigationItem(ADAPTER_KEY_STARRED, context.getString(R.string.label_favorites), favoritesCount, R.drawable.selector_favorites, FAVORITES)); + + final long accountId = categories.isEmpty() ? -1 : categories.get(0).getAccountId(); + final var root = buildTree(categories); + flatten(context, root, 0, accountId, expandedCategories, items); + return items; + } + + @NonNull + private static CategoryTreeNode buildTree(@NonNull List categories) { + final var root = new CategoryTreeNode("", ""); + for (final var category : categories) { + final String[] segments = category.getCategory().split(CATEGORY_SEPARATOR); + var node = root; + final var path = new StringBuilder(); + for (final String segment : segments) { + if (path.length() > 0) { + path.append(CATEGORY_SEPARATOR); + } + path.append(segment); + final String currentPath = path.toString(); + node = node.children.computeIfAbsent(segment, label -> new CategoryTreeNode(currentPath, label)); + } + node.directNotes = category.getTotalNotes(); + } + return root; + } + + private static void flatten(@NonNull Context context, + @NonNull CategoryTreeNode parent, + int depth, + long accountId, + @NonNull Set expandedCategories, + @NonNull List items) { + for (final var node : parent.children.values()) { + final boolean hasChildren = node.hasChildren(); + final boolean expanded = hasChildren && expandedCategories.contains(node.path); + final int notes = expanded ? node.directNotes : node.totalNotes(); + final var item = new NavigationItem.CategoryNavigationItem("category:" + node.path, node.label, notes, iconFor(context, node.label, hasChildren, expanded, depth), accountId, node.path); + item.depth = depth; + item.expandState = expandStateFor(hasChildren, expanded); + items.add(item); + + if (expanded) { + flatten(context, node, depth + 1, accountId, expandedCategories, items); + } + } + } + + @NonNull + private static CategoryExpandState expandStateFor(boolean hasChildren, boolean expanded) { + if (!hasChildren) { + return CategoryExpandState.NOT_EXPANDABLE; + } + return expanded ? CategoryExpandState.EXPANDED : CategoryExpandState.COLLAPSED; + } + + @DrawableRes + private static int iconFor(@NonNull Context context, @NonNull String label, boolean hasChildren, boolean expanded, int depth) { + if (hasChildren) { + if (expanded) { + return NavigationAdapter.ICON_MULTIPLE_OPEN; + } + return depth == 0 ? NavigationAdapter.ICON_MULTIPLE : NavigationAdapter.ICON_SUB_MULTIPLE; + } + final int specialIcon = DisplayUtils.getCategoryIcon(context, label); + if (specialIcon != NavigationAdapter.ICON_FOLDER) { + return specialIcon; + } + return depth == 0 ? NavigationAdapter.ICON_FOLDER : NavigationAdapter.ICON_SUB_FOLDER; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeNode.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeNode.java new file mode 100644 index 000000000..61cbe1cd2 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeNode.java @@ -0,0 +1,45 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.main.navigation; + +import androidx.annotation.NonNull; + +import java.util.Map; +import java.util.TreeMap; + +/** + * A single node in the category hierarchy. The tree is built from the flat list of categories where + * each category path is separated by {@code /}. Children are kept sorted by their label so the + * resulting navigation is deterministic regardless of the order the categories arrive in. + */ +class CategoryTreeNode { + + @NonNull + final String path; + @NonNull + final String label; + int directNotes = 0; + @NonNull + final Map children = new TreeMap<>(); + + CategoryTreeNode(@NonNull String path, @NonNull String label) { + this.path = path; + this.label = label; + } + + boolean hasChildren() { + return !children.isEmpty(); + } + + int totalNotes() { + int total = directNotes; + for (final var child : children.values()) { + total += child.totalNotes(); + } + return total; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java index 562acc190..fd3392fd2 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationItem.java @@ -29,6 +29,9 @@ public class NavigationItem { public Integer count; @Nullable public ENavigationCategoryType type; + public int depth = 0; + @NonNull + public CategoryExpandState expandState = CategoryExpandState.NOT_EXPANDABLE; public NavigationItem(@NonNull String id, @NonNull String label, @Nullable Integer count, @DrawableRes int icon) { this.id = id; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java index ba83f3851..ea6912347 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/navigation/NavigationViewHolder.java @@ -71,9 +71,7 @@ public void bind(@NonNull NavigationItem item, @ColorInt int color, String selec view.setSelected(isSelected); final var params = (ViewGroup.MarginLayoutParams) view.getLayoutParams(); - params.leftMargin = item.icon == NavigationAdapter.ICON_SUB_FOLDER || item.icon == NavigationAdapter.ICON_SUB_MULTIPLE - ? view.getResources().getDimensionPixelSize(R.dimen.spacer_3x) - : 0; + params.leftMargin = item.depth * view.getResources().getDimensionPixelSize(R.dimen.spacer_3x); view.requestLayout(); } } \ No newline at end of file diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java index 47b332c51..8cb8b637f 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/DisplayUtils.java @@ -24,6 +24,7 @@ import android.view.View; import android.view.WindowInsets; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.core.view.ViewCompat; @@ -70,10 +71,20 @@ public static List convertToCategoryNavig } public static NavigationItem.CategoryNavigationItem convertToCategoryNavigationItem(@NonNull Context context, @NonNull CategoryWithNotesCount counter) { + final int icon = getCategoryIcon(context, counter.getCategory()); + return new NavigationItem.CategoryNavigationItem("category:" + counter.getCategory(), counter.getCategory(), counter.getTotalNotes(), icon, counter.getAccountId(), counter.getCategory()); + } + + /** + * Resolves the icon for a category. Categories whose (localized or english) name matches one of + * the {@link #SPECIAL_CATEGORY_REPLACEMENTS} get a dedicated icon, everything else the default + * {@link NavigationAdapter#ICON_FOLDER}. + */ + @DrawableRes + public static int getCategoryIcon(@NonNull Context context, @NonNull String categoryLabel) { final var res = context.getResources(); final var englishRes = getEnglishResources(context); - final String category = counter.getCategory().replaceAll("\\s+", ""); - int icon = NavigationAdapter.ICON_FOLDER; + final String category = categoryLabel.replaceAll("\\s+", ""); for (Map.Entry> replacement : SPECIAL_CATEGORY_REPLACEMENTS.entrySet()) { if (Stream.concat( @@ -81,11 +92,10 @@ public static NavigationItem.CategoryNavigationItem convertToCategoryNavigationI replacement.getValue().stream().map(englishRes::getString) ).map(str -> str.replaceAll("\\s+", "")) .anyMatch(r -> r.equalsIgnoreCase(category))) { - icon = replacement.getKey(); - break; + return replacement.getKey(); } } - return new NavigationItem.CategoryNavigationItem("category:" + counter.getCategory(), counter.getCategory(), counter.getTotalNotes(), icon, counter.getAccountId(), counter.getCategory()); + return NavigationAdapter.ICON_FOLDER; } @NonNull diff --git a/app/src/test/java/it/niedermann/owncloud/notes/main/MainViewModelTest.java b/app/src/test/java/it/niedermann/owncloud/notes/main/MainViewModelTest.java index f8f3cdea9..58cc75b63 100644 --- a/app/src/test/java/it/niedermann/owncloud/notes/main/MainViewModelTest.java +++ b/app/src/test/java/it/niedermann/owncloud/notes/main/MainViewModelTest.java @@ -6,34 +6,23 @@ */ package it.niedermann.owncloud.notes.main; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import android.content.Context; - import androidx.arch.core.executor.testing.InstantTaskExecutorRule; import androidx.test.core.app.ApplicationProvider; import com.nextcloud.android.sso.exceptions.UnknownErrorException; -import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.Collections; import java.util.List; -import it.niedermann.owncloud.notes.main.navigation.NavigationItem; -import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; -import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; - @RunWith(RobolectricTestRunner.class) @Config(sdk = 36) public class MainViewModelTest { @@ -41,94 +30,6 @@ public class MainViewModelTest { @Rule public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); - private Context context; - - private Method fromCategoriesWithNotesCount; - - @Before - public void setup() throws NoSuchMethodException { - context = ApplicationProvider.getApplicationContext(); - fromCategoriesWithNotesCount = MainViewModel.class.getDeclaredMethod("fromCategoriesWithNotesCount", Context.class, String.class, List.class, Integer.TYPE, Integer.TYPE); - fromCategoriesWithNotesCount.setAccessible(true); - } - - @Test - public void fromCategoriesWithNotesCount_nothing_expanded() throws InvocationTargetException, IllegalAccessException { - //noinspection unchecked - final var navigationItems = (List) fromCategoriesWithNotesCount.invoke(null, context, "", getSaneCategoriesWithNotesCount(), 56, 0); - - assertNotNull(navigationItems); - assertEquals(5, navigationItems.size()); - assertEquals(ENavigationCategoryType.RECENT, navigationItems.get(0).type); - assertEquals(ENavigationCategoryType.FAVORITES, navigationItems.get(1).type); - assertEquals("Foo", navigationItems.get(2).label); - assertEquals("Bar", navigationItems.get(3).label); - assertEquals("Baz", navigationItems.get(4).label); - } - - @Test - public void fromCategoriesWithNotesCount_Bar_expanded() throws InvocationTargetException, IllegalAccessException { - //noinspection unchecked - final var navigationItems = (List) fromCategoriesWithNotesCount.invoke(null, context, "Bar", getSaneCategoriesWithNotesCount(), 56, 0); - - assertNotNull(navigationItems); - assertEquals(9, navigationItems.size()); - assertEquals(ENavigationCategoryType.RECENT, navigationItems.get(0).type); - assertEquals(ENavigationCategoryType.FAVORITES, navigationItems.get(1).type); - assertEquals("Foo", navigationItems.get(2).label); - assertEquals("Bar", navigationItems.get(3).label); - assertEquals("abc", navigationItems.get(4).label); - assertEquals("xyz", navigationItems.get(5).label); - assertEquals("aaa", navigationItems.get(6).label); - assertEquals("ddd", navigationItems.get(7).label); - assertEquals("Baz", navigationItems.get(8).label); - } - - @Test - public void fromCategoriesWithNotesCount_invalid_expanded() throws InvocationTargetException, IllegalAccessException { - //noinspection unchecked - final var navigationItems = (List) fromCategoriesWithNotesCount.invoke(null, context, "ThisCategoryDoesNotExist", getSaneCategoriesWithNotesCount(), 56, 0); - - assertNotNull(navigationItems); - assertEquals(5, navigationItems.size()); - assertEquals(ENavigationCategoryType.RECENT, navigationItems.get(0).type); - assertEquals(ENavigationCategoryType.FAVORITES, navigationItems.get(1).type); - assertEquals("Foo", navigationItems.get(2).label); - assertEquals("Bar", navigationItems.get(3).label); - assertEquals("Baz", navigationItems.get(4).label); - } - - /** - * Expanded sub categories are not supported and should therefore be treated like an unknown category - */ - @Test - public void fromCategoriesWithNotesCount_subcategory_expanded() throws InvocationTargetException, IllegalAccessException { - //noinspection unchecked - final var navigationItems = (List) fromCategoriesWithNotesCount.invoke(null, context, "Bar/abc", getSaneCategoriesWithNotesCount(), 56, 0); - - assertNotNull(navigationItems); - assertEquals(5, navigationItems.size()); - assertEquals(ENavigationCategoryType.RECENT, navigationItems.get(0).type); - assertEquals(ENavigationCategoryType.FAVORITES, navigationItems.get(1).type); - assertEquals("Foo", navigationItems.get(2).label); - assertEquals("Bar", navigationItems.get(3).label); - assertEquals("Baz", navigationItems.get(4).label); - } - - @Test - public void fromCategoriesWithNotesCount_only_deep_category_without_favorites() throws InvocationTargetException, IllegalAccessException { - //noinspection unchecked - final var navigationItems = (List) fromCategoriesWithNotesCount.invoke(null, context, "Bar/abc", List.of( - new CategoryWithNotesCount(1, "Bar/abc/def", 5) - ), 0, 0); - - assertNotNull(navigationItems); - assertEquals(3, navigationItems.size()); - assertEquals(ENavigationCategoryType.RECENT, navigationItems.get(0).type); - assertEquals(ENavigationCategoryType.FAVORITES, navigationItems.get(1).type); - assertEquals("Bar", navigationItems.get(2).label); - } - @Test public void containsNonInfrastructureRelatedItems() { //noinspection ConstantConditions @@ -162,17 +63,4 @@ public void containsNonInfrastructureRelatedItems() { assertTrue(vm.containsNonInfrastructureRelatedItems(List.of(new RuntimeException("Foo", new Exception("Software caused connection abort"))))); } - - private static List getSaneCategoriesWithNotesCount() { - return List.of( - new CategoryWithNotesCount(1, "Foo", 13), - new CategoryWithNotesCount(1, "Bar", 30), - new CategoryWithNotesCount(1, "Bar/abc", 10), - new CategoryWithNotesCount(1, "Bar/abc/def", 5), - new CategoryWithNotesCount(1, "Bar/xyz/zyx", 10), - new CategoryWithNotesCount(1, "Bar/aaa/bbb", 8), - new CategoryWithNotesCount(1, "Bar/ddd", 2), - new CategoryWithNotesCount(1, "Baz", 13) - ); - } } diff --git a/app/src/test/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapperTest.java b/app/src/test/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapperTest.java new file mode 100644 index 000000000..ead397b17 --- /dev/null +++ b/app/src/test/java/it/niedermann/owncloud/notes/main/navigation/CategoryTreeMapperTest.java @@ -0,0 +1,165 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.main.navigation; + +import static org.junit.Assert.assertEquals; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import it.niedermann.owncloud.notes.persistence.entity.CategoryWithNotesCount; +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 36) +public class CategoryTreeMapperTest { + + private Context context; + + @Before + public void setup() { + context = ApplicationProvider.getApplicationContext(); + } + + @Test + public void nothingExpandedShowsRootsSortedAndCollapsed() { + final var items = CategoryTreeMapper.map(context, Collections.emptySet(), getSaneCategories(), 56, 0); + + assertEquals(5, items.size()); + assertEquals(ENavigationCategoryType.RECENT, items.get(0).type); + assertEquals(ENavigationCategoryType.FAVORITES, items.get(1).type); + + assertEquals("Bar", items.get(2).label); + assertEquals(0, items.get(2).depth); + assertEquals(CategoryExpandState.COLLAPSED, items.get(2).expandState); + assertEquals(NavigationAdapter.ICON_MULTIPLE, items.get(2).icon); + // Bar direct + all descendants: 30 + 10 + 5 + 10 + 8 + 2 + assertEquals(65, (int) items.get(2).count); + + assertEquals("Baz", items.get(3).label); + assertEquals(CategoryExpandState.NOT_EXPANDABLE, items.get(3).expandState); + assertEquals("Foo", items.get(4).label); + } + + @Test + public void unknownExpandedCategoryIsIgnored() { + final var items = CategoryTreeMapper.map(context, Set.of("ThisDoesNotExist"), getSaneCategories(), 56, 0); + + assertEquals(5, items.size()); + assertEquals("Bar", items.get(2).label); + assertEquals("Baz", items.get(3).label); + assertEquals("Foo", items.get(4).label); + } + + @Test + public void expandingRootRevealsDirectSubCategories() { + final var items = CategoryTreeMapper.map(context, Set.of("Bar"), getSaneCategories(), 56, 0); + + assertEquals(9, items.size()); + assertEquals("Bar", items.get(2).label); + assertEquals(CategoryExpandState.EXPANDED, items.get(2).expandState); + assertEquals(NavigationAdapter.ICON_MULTIPLE_OPEN, items.get(2).icon); + assertEquals(30, (int) items.get(2).count); + + assertEquals("aaa", items.get(3).label); + assertEquals(1, items.get(3).depth); + assertEquals(CategoryExpandState.COLLAPSED, items.get(3).expandState); + assertEquals(8, (int) items.get(3).count); + + assertEquals("abc", items.get(4).label); + assertEquals(15, (int) items.get(4).count); + assertEquals("ddd", items.get(5).label); + assertEquals(CategoryExpandState.NOT_EXPANDABLE, items.get(5).expandState); + assertEquals("xyz", items.get(6).label); + + assertEquals("Baz", items.get(7).label); + assertEquals(0, items.get(7).depth); + assertEquals("Foo", items.get(8).label); + } + + @Test + public void expandingSubCategoryRevealsDeeperLevel() { + final var items = CategoryTreeMapper.map(context, Set.of("Bar", "Bar/abc"), getSaneCategories(), 56, 0); + + assertEquals(10, items.size()); + assertEquals("abc", items.get(4).label); + assertEquals(CategoryExpandState.EXPANDED, items.get(4).expandState); + assertEquals(10, (int) items.get(4).count); + + assertEquals("def", items.get(5).label); + assertEquals(2, items.get(5).depth); + assertEquals(CategoryExpandState.NOT_EXPANDABLE, items.get(5).expandState); + assertEquals(5, (int) items.get(5).count); + } + + @Test + public void deepCategoryWithoutDirectNotesCreatesSyntheticParents() { + final var input = List.of(new CategoryWithNotesCount(1, "Bar/abc/def", 5)); + + final var collapsed = CategoryTreeMapper.map(context, Collections.emptySet(), input, 0, 0); + assertEquals(3, collapsed.size()); + assertEquals("Bar", collapsed.get(2).label); + assertEquals(5, (int) collapsed.get(2).count); + + final var expanded = CategoryTreeMapper.map(context, Set.of("Bar", "Bar/abc"), input, 0, 0); + assertEquals(5, expanded.size()); + assertEquals("Bar", expanded.get(2).label); + assertEquals(0, (int) expanded.get(2).count); + assertEquals("abc", expanded.get(3).label); + assertEquals("def", expanded.get(4).label); + assertEquals(2, expanded.get(4).depth); + } + + /** + * A sibling category whose name breaks the alphabetical adjacency of a parent and its children + * (e.g. "Work-2" sorts between "Work" and "Work/…") must not produce a duplicated parent entry. + */ + @Test + public void siblingBreakingAdjacencyDoesNotDuplicateParent() { + final var input = List.of( + new CategoryWithNotesCount(1, "Work", 5), + new CategoryWithNotesCount(1, "Work-2", 1), + new CategoryWithNotesCount(1, "Work/ProjectA", 4), + new CategoryWithNotesCount(1, "Work/ProjectB", 2) + ); + + final var items = CategoryTreeMapper.map(context, Set.of("Work"), input, 12, 0); + + assertEquals(6, items.size()); + assertEquals("Work", items.get(2).label); + assertEquals(CategoryExpandState.EXPANDED, items.get(2).expandState); + assertEquals("ProjectA", items.get(3).label); + assertEquals(1, items.get(3).depth); + assertEquals("ProjectB", items.get(4).label); + assertEquals("Work-2", items.get(5).label); + assertEquals(0, items.get(5).depth); + } + + private static List getSaneCategories() { + return List.of( + new CategoryWithNotesCount(1, "Foo", 13), + new CategoryWithNotesCount(1, "Bar", 30), + new CategoryWithNotesCount(1, "Bar/abc", 10), + new CategoryWithNotesCount(1, "Bar/abc/def", 5), + new CategoryWithNotesCount(1, "Bar/xyz/zyx", 10), + new CategoryWithNotesCount(1, "Bar/aaa/bbb", 8), + new CategoryWithNotesCount(1, "Bar/ddd", 2), + new CategoryWithNotesCount(1, "Baz", 13) + ); + } +}