Search also for categories with appsearch

This commit is contained in:
Torsten Grote
2025-07-29 14:06:19 -03:00
parent 1339acf5b7
commit 6b3a4ddea5
8 changed files with 110 additions and 29 deletions

View File

@@ -35,7 +35,7 @@ data class AppDocument(
serializer = FileV2Serializer::class,
)
val icon: FileV2?,
)
) : AppSearchDoc
class FileV2Serializer : StringSerializer<FileV2> {
override fun serialize(instance: FileV2): String {

View File

@@ -9,6 +9,7 @@ import androidx.appsearch.app.SetSchemaRequest
import androidx.appsearch.localstorage.LocalStorage
import androidx.concurrent.futures.await
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation
@@ -21,6 +22,8 @@ import org.fdroid.utils.IoDispatcher
import javax.inject.Inject
import javax.inject.Singleton
sealed interface AppSearchDoc
@Singleton
class AppSearchManager @Inject constructor(
@ApplicationContext val context: Context,
@@ -41,7 +44,7 @@ class AppSearchManager @Inject constructor(
try {
val setSchemaRequest = SetSchemaRequest.Builder()
.setForceOverride(true)
.addDocumentClasses(AppDocument::class.java)
.addDocumentClasses(AppDocument::class.java, CategoryDocument::class.java)
.build()
val response = session.setSchemaAsync(setSchemaRequest).await()
response.migrationFailures.forEach { failure ->
@@ -68,7 +71,7 @@ class AppSearchManager @Inject constructor(
}
@SuppressLint("RequiresFeature") // we only use local search which supports this
suspend fun search(s: String = "F-Droid"): List<AppDocument> {
suspend fun search(s: String = "F-Droid"): List<AppSearchDoc> {
awaitInitialization()
val weights = mapOf(
"name" to 100.0,
@@ -95,7 +98,8 @@ class AppSearchManager @Inject constructor(
// we just use a single page for simplicity
val resultList = results.nextPageAsync.await()
resultList.map { r ->
r.getDocument(AppDocument::class.java)
if (r.genericDocument.namespace == "app") r.getDocument(AppDocument::class.java)
else r.getDocument(CategoryDocument::class.java)
}
}
}
@@ -146,6 +150,26 @@ class AppSearchManager @Inject constructor(
}
log.info { "Added ${result.successes.size} apps. Flushing to disk..." }
session.requestFlushAsync()
val categoryDocs = db.getRepositoryDao().getLiveCategories().asFlow().first().map {
CategoryDocument(
id = it.id,
repoId = it.repoId,
name = it.getName(localeList),
description = it.getDescription(localeList),
icon = it.getIcon(localeList),
)
}
log.debug { "Got ${categoryDocs.size} categories. Adding to appsearch.." }
val categoryPutRequest = PutDocumentsRequest.Builder()
.addDocuments(categoryDocs)
.build()
val categoryResult = session.putAsync(categoryPutRequest).await()
if (!categoryResult.isSuccess) log.error {
"Error putting documents: $result"
}
log.info { "Added ${categoryResult.successes.size} categories. Flushing to disk..." }
session.requestFlushAsync()
}
private suspend fun awaitInitialization() {

View File

@@ -0,0 +1,27 @@
package org.fdroid.appsearch
import androidx.appsearch.annotation.Document
import androidx.appsearch.app.AppSearchSchema.LongPropertyConfig
import androidx.appsearch.app.AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_NONE
import androidx.appsearch.app.AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES
import org.fdroid.index.v2.FileV2
@Document
data class CategoryDocument(
@Document.Namespace
val namespace: String = "category",
@Document.Id
val id: String,
@Document.LongProperty(indexingType = LongPropertyConfig.INDEXING_TYPE_RANGE)
val repoId: Long,
@Document.StringProperty(indexingType = INDEXING_TYPE_PREFIXES)
val name: String?,
@Document.StringProperty(indexingType = INDEXING_TYPE_PREFIXES)
val description: String?,
@Document.StringProperty(
indexingType = INDEXING_TYPE_NONE,
serializer = FileV2Serializer::class,
)
val icon: FileV2?,
) : AppSearchDoc

View File

@@ -0,0 +1,9 @@
package org.fdroid.appsearch
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
data class SearchResults(
val apps: List<AppListItem>,
val categories: List<CategoryItem>,
)

View File

@@ -27,9 +27,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.appsearch.SearchResults
import org.fdroid.fdroid.ui.theme.FDroidContent
import org.fdroid.next.R
import org.fdroid.ui.lists.AppListItem
import org.fdroid.ui.NavigationKey
import org.fdroid.ui.categories.CategoryCard
import org.fdroid.ui.lists.AppListRow
import org.fdroid.ui.utils.BigLoadingIndicator
@@ -37,9 +39,9 @@ import org.fdroid.ui.utils.BigLoadingIndicator
@OptIn(ExperimentalMaterial3Api::class)
fun AppsSearch(
searchBarState: SearchBarState,
searchResults: List<AppListItem>?,
searchResults: SearchResults?,
onSearch: suspend (String) -> Unit,
onItemSelected: (AppListItem) -> Unit,
onNav: (NavigationKey) -> Unit,
onSearchCleared: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -77,7 +79,7 @@ fun AppsSearch(
) {
if (searchResults == null) {
if (textFieldState.text.length >= 3) BigLoadingIndicator()
} else if (searchResults.isEmpty()) {
} else if (searchResults.apps.isEmpty()) {
Text(
text = stringResource(R.string.search_no_results),
textAlign = TextAlign.Center,
@@ -87,9 +89,18 @@ fun AppsSearch(
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(searchResults, key = { it.packageName }) { item ->
items(
searchResults.categories,
key = { it.id },
contentType = { "category" }) { item ->
CategoryCard(categoryItem = item, onNav = onNav)
}
items(
searchResults.apps,
key = { it.packageName },
contentType = { "app" }) { item ->
AppListRow(item, false, modifier.clickable {
onItemSelected(item)
onNav(NavigationKey.AppDetails(item.packageName))
})
}
}
@@ -116,7 +127,7 @@ private fun AppsSearchEmptyPreview() {
FDroidContent {
Box(Modifier.fillMaxSize()) {
val state = rememberSearchBarState(SearchBarValue.Expanded)
AppsSearch(state, emptyList(), {}, {}, {})
AppsSearch(state, SearchResults(emptyList(), emptyList()), {}, {}, {})
}
}
}

View File

@@ -120,9 +120,7 @@ fun Discover(
searchBarState = searchBarState,
searchResults = discoverModel.searchResults,
onSearch = onSearch,
onItemSelected = {
onNav(NavigationKey.AppDetails(it.packageName))
},
onNav = onNav,
onSearchCleared = onSearchCleared,
modifier = Modifier.padding(top = 8.dp),
)

View File

@@ -3,17 +3,17 @@ package org.fdroid.ui.discover
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.fdroid.appsearch.SearchResults
import org.fdroid.database.Repository
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
@Composable
fun DiscoverPresenter(
appsFlow: Flow<List<AppDiscoverItem>>,
categoriesFlow: Flow<List<CategoryItem>>,
repositoriesFlow: Flow<List<Repository>>,
searchResultsFlow: MutableStateFlow<List<AppListItem>?>,
searchResultsFlow: StateFlow<SearchResults?>,
): DiscoverModel {
val apps = appsFlow.collectAsState(null).value
val categories = categoriesFlow.collectAsState(null).value
@@ -48,5 +48,5 @@ data class LoadedDiscoverModel(
val newApps: List<AppDiscoverItem>,
val recentlyUpdatedApps: List<AppDiscoverItem>,
val categories: List<CategoryItem>?,
val searchResults: List<AppListItem>? = null,
val searchResults: SearchResults? = null,
) : DiscoverModel()

View File

@@ -15,7 +15,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.fdroid.appsearch.AppDocument
import org.fdroid.appsearch.AppSearchManager
import org.fdroid.appsearch.CategoryDocument
import org.fdroid.appsearch.SearchResults
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.getDownloadRequest
import org.fdroid.index.RepoManager
@@ -64,7 +67,7 @@ class DiscoverViewModel @Inject constructor(
)
}.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) }
}
private val searchResults = MutableStateFlow<List<AppListItem>?>(null)
private val searchResults = MutableStateFlow<SearchResults?>(null)
val localeList = LocaleListCompat.getDefault()
val discoverModel: StateFlow<DiscoverModel> = scope.launchMolecule(mode = ContextClock) {
@@ -77,17 +80,26 @@ class DiscoverViewModel @Inject constructor(
}
suspend fun search(term: String) = withContext(ioScope.coroutineContext) {
searchResults.value = appSearchManager.search(term).mapNotNull {
val repository = repoManager.getRepository(it.repoId)
?: return@mapNotNull null
AppListItem(
packageName = it.packageName,
name = it.name ?: "Unknown",
summary = it.summary ?: "Unknown",
lastUpdated = it.lastUpdated,
iconDownloadRequest = it.icon?.getDownloadRequest(repository),
)
val categories = mutableListOf<CategoryItem>()
val apps = mutableListOf<AppListItem>()
appSearchManager.search(term).forEach {
if (it is AppDocument) {
val repository = repoManager.getRepository(it.repoId) ?: return@forEach
AppListItem(
packageName = it.packageName,
name = it.name ?: "Unknown",
summary = it.summary ?: "",
lastUpdated = it.lastUpdated,
iconDownloadRequest = it.icon?.getDownloadRequest(repository),
).also { app -> apps.add(app) }
} else if (it is CategoryDocument) {
CategoryItem(
id = it.id,
name = it.name ?: "Unknown category",
).also { c -> categories.add(c) }
}
}
searchResults.value = SearchResults(apps, categories)
}
fun onSearchCleared() {