Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/unreleased/4674
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Bugfix: Fix spaces search on Kiteworks accounts

All matching personal spaces have been included in the spaces search filter for multi-personal accounts.

https://github.com/owncloud/android/issues/4674
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,7 @@ class SpacesListFragment :
collectLatestLifecycleFlow(spacesListViewModel.spacesList) { uiState ->
var spacesToListFiltered: List<OCSpace>
if (uiState.searchFilter != "") {
spacesToListFiltered =
uiState.spaces.filter { it.name.lowercase().contains(uiState.searchFilter.lowercase()) && !it.isPersonal &&
shouldShowDisabledSpace(it) }
val personalSpace = uiState.spaces.find { it.isPersonal }
personalSpace?.let {
spacesToListFiltered = spacesToListFiltered.toMutableList().apply {
add(0, personalSpace)
}
}
spacesToListFiltered = uiState.spaces.filterSpaces(uiState.searchFilter, isMultiPersonal, ::shouldShowDisabledSpace)
showOrHideEmptyView(spacesToListFiltered)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: 0% on this branch — below the 85% gate. I searched the repo (no local checkout, used GitHub code search) and confirmed there is no SpacesListFragmentTest, no SpacesListViewModelTest, and no reference to searchFilter/isMultiPersonal anywhere in src/test or src/androidTest. The PR description only lists manual QA steps.

Root cause: the new isMultiPersonal branch (and the personal-space pinning it now skips) lives inline inside a Fragment lambda, and this codebase has no precedent for unit-testing Fragments directly (Robolectric isn't wired into owncloudApp; the existing pattern is plain JUnit4 + MockK against ViewModels, see owncloudApp/src/test/java/.../viewmodels/*ViewModelTest.kt).

Suggested fix — extract to a pure, testable function so this exact branch can be unit tested without Robolectric:

object SpacesFilter {
    fun filterSpaces(
        spaces: List<OCSpace>,
        searchFilter: String,
        isMultiPersonal: Boolean,
        shouldShowDisabledSpace: (OCSpace) -> Boolean,
    ): List<OCSpace> {
        if (searchFilter.isEmpty()) return spaces.filter { shouldShowDisabledSpace(it) }
        val lowerFilter = searchFilter.lowercase()
        var filtered = if (isMultiPersonal) {
            spaces.filter { it.name.lowercase().contains(lowerFilter) && shouldShowDisabledSpace(it) }
        } else {
            spaces.filter { it.name.lowercase().contains(lowerFilter) && !it.isPersonal && shouldShowDisabledSpace(it) }
        }
        if (!isMultiPersonal) {
            spaces.find { it.isPersonal }?.let { personalSpace ->
                filtered = filtered.toMutableList().apply { add(0, personalSpace) }
            }
        }
        return filtered
    }
}

SpacesListFragment would then call SpacesFilter.filterSpaces(uiState.spaces, uiState.searchFilter, isMultiPersonal) { shouldShowDisabledSpace(it) }. Proposed test (owncloudApp/src/test/java/com/owncloud/android/presentation/spaces/SpacesFilterTest.kt), covering multi-personal multi-match, single-personal pinning, no-match, and empty-filter cases:

class SpacesFilterTest {
    private fun space(name: String, isPersonal: Boolean, isDisabled: Boolean = false): OCSpace =
        mockk<OCSpace>(relaxed = true).also {
            every { it.name } returns name
            every { it.isPersonal } returns isPersonal
            every { it.isDisabled } returns isDisabled
        }
    private val showAll: (OCSpace) -> Boolean = { true }

    @Test fun `multi-personal with filter matches multiple personal spaces`() {
        val a = space("Personal Alpha", true); val b = space("Personal Beta", true)
        val project = space("Marketing Project", false)
        assertEquals(listOf(a, b), SpacesFilter.filterSpaces(listOf(a, b, project), "personal", true, showAll))
    }

    @Test fun `single-personal with filter still pins one personal space at top`() {
        val personal = space("Personal", true); val match = space("Engineering Docs", false); val noMatch = space("Random", false)
        assertEquals(listOf(personal, match), SpacesFilter.filterSpaces(listOf(personal, match, noMatch), "eng", false, showAll))
    }

    @Test fun `no matches returns empty list`() {
        val personal = space("Personal", true); val project = space("Marketing", false)
        assertEquals(emptyList<OCSpace>(), SpacesFilter.filterSpaces(listOf(personal, project), "zzz-no-match", false, showAll))
    }

    @Test fun `empty filter returns all spaces passing disabled-space check`() {
        val personal = space("Personal", true); val project = space("Marketing", false); val disabled = space("Archived", false, true)
        assertEquals(listOf(personal, project), SpacesFilter.filterSpaces(listOf(personal, project, disabled), "", true) { !it.isDisabled })
    }
}

This also resolves the MVVM nit below: filtering is business logic and reads better as a pure function than inline in the Fragment's flow collector.


Generated by Claude Code

spacesListAdapter.setData(spacesToListFiltered, isMultiPersonal)
} else {
Expand Down Expand Up @@ -518,3 +510,16 @@ class SpacesListFragment :
}

}

internal fun List<OCSpace>.filterSpaces(
query: String,
multi: Boolean,
visible: (OCSpace) -> Boolean,
): List<OCSpace> {
val queryLowercase = query.lowercase()
val spacesMatchingName = filter { it.name.lowercase().contains(queryLowercase) }
val matchingSpaces = spacesMatchingName.filter { (multi || !it.isPersonal) && visible(it) }
if (multi) return matchingSpaces
val personalSpace = find { it.isPersonal } ?: return matchingSpaces
return listOf(personalSpace) + matchingSpaces
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* ownCloud Android client application
*
* @author Matt Van Horn
* Copyright (C) 2026 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package com.owncloud.android.presentation.spaces

import com.owncloud.android.testutil.OC_SPACE_PERSONAL
import org.junit.Assert.assertEquals
import org.junit.Test

class SpacesListFragmentTest {
@Test
fun `filterSpaces filters multi-personal names and preserves single-personal pinning`() {
val personalSpaces = listOf(OC_SPACE_PERSONAL.copy(name = "Alice Personal"), OC_SPACE_PERSONAL.copy(name = "Bob Personal"))
val singlePersonalSpaces = listOf(OC_SPACE_PERSONAL.copy(name = "Project Personal"))
assertEquals(personalSpaces, personalSpaces.filterSpaces("personal", true) { true })
assertEquals(listOf(personalSpaces[0]), personalSpaces.filterSpaces("alice", true) { true })
assertEquals(singlePersonalSpaces, singlePersonalSpaces.filterSpaces("project", false) { true })
}
}