diff --git a/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt b/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt index 1746b1fae..de6e97725 100644 --- a/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt +++ b/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt @@ -35,7 +35,7 @@ data class AppDocument( serializer = FileV2Serializer::class, ) val icon: FileV2?, -) +) : AppSearchDoc class FileV2Serializer : StringSerializer { override fun serialize(instance: FileV2): String { diff --git a/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt b/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt index 116f429a2..5ad87354c 100644 --- a/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt +++ b/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt @@ -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 { + suspend fun search(s: String = "F-Droid"): List { 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() { diff --git a/next/src/main/kotlin/org/fdroid/appsearch/CategoryDocument.kt b/next/src/main/kotlin/org/fdroid/appsearch/CategoryDocument.kt new file mode 100644 index 000000000..1cb53bdeb --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/appsearch/CategoryDocument.kt @@ -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 diff --git a/next/src/main/kotlin/org/fdroid/appsearch/SearchResults.kt b/next/src/main/kotlin/org/fdroid/appsearch/SearchResults.kt new file mode 100644 index 000000000..868b9e3b6 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/appsearch/SearchResults.kt @@ -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, + val categories: List, +) diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt b/next/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt index 5e38447c5..a2648a35d 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt @@ -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?, + 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()), {}, {}, {}) } } } diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt b/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt index 42b01ebf9..f2e50ec52 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt @@ -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), ) diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt index 9096ddd85..0ba5dc256 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverPresenter.kt @@ -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>, categoriesFlow: Flow>, repositoriesFlow: Flow>, - searchResultsFlow: MutableStateFlow?>, + searchResultsFlow: StateFlow, ): DiscoverModel { val apps = appsFlow.collectAsState(null).value val categories = categoriesFlow.collectAsState(null).value @@ -48,5 +48,5 @@ data class LoadedDiscoverModel( val newApps: List, val recentlyUpdatedApps: List, val categories: List?, - val searchResults: List? = null, + val searchResults: SearchResults? = null, ) : DiscoverModel() diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt index 56ef50fd6..132e29845 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt @@ -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?>(null) + private val searchResults = MutableStateFlow(null) val localeList = LocaleListCompat.getDefault() val discoverModel: StateFlow = 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() + val apps = mutableListOf() + 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() {