From 1339acf5b7e16be433af5d1337e033c76ce77e60 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 25 Jul 2025 17:46:09 -0300 Subject: [PATCH] Initial implementation of appsearch --- build.gradle | 1 + gradle/libs.versions.toml | 9 + next/build.gradle.kts | 7 + .../org/fdroid/appsearch/AppDocument.kt | 49 ++++++ .../org/fdroid/appsearch/AppSearchManager.kt | 157 ++++++++++++++++++ .../org/fdroid/repo/RepoUpdateManager.kt | 30 +++- next/src/main/kotlin/org/fdroid/ui/Main.kt | 4 +- .../fdroid/ui/details/AppDetailsViewModel.kt | 2 + .../fdroid/ui/discover/AppSearchInputField.kt | 22 ++- .../org/fdroid/ui/discover/AppsSearch.kt | 99 +++++++++-- .../kotlin/org/fdroid/ui/discover/Discover.kt | 12 +- .../fdroid/ui/discover/DiscoverPresenter.kt | 6 + .../fdroid/ui/discover/DiscoverViewModel.kt | 26 +++ .../ui/repositories/RepositoriesViewModel.kt | 5 + next/src/main/res/values/strings-next.xml | 1 + 15 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt create mode 100644 next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt diff --git a/build.gradle b/build.gradle index 38cbe29c8..2a456ed9c 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,7 @@ plugins { alias libs.plugins.android.application apply false alias libs.plugins.android.library apply false alias libs.plugins.android.ksp apply false + alias libs.plugins.android.kapt apply false alias libs.plugins.android.hilt apply false alias libs.plugins.jetbrains.kotlin.android apply false alias libs.plugins.jetbrains.kotlin.multiplatform apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed1a2453c..edbc0b6eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ compileSdk = "36" kotlin = "2.2.21" androidGradlePlugin = "8.11.1" # 8.12.0 pulls in aapt2 which has issue on buildserver androidKspPlugin = "2.2.21-2.0.4" # first version needs to match kotlin version +androidKaptPlugin = "2.2.0" hilt = "2.56.2" hiltWork = "1.2.0" hiltNavigationCompose = "1.2.0" @@ -38,6 +39,8 @@ androidxGridlayout = "1.1.0" androidxComposeBom = "2025.09.01" androidxActivityCompose = "1.11.0" accompanistDrawablepainter = "0.37.3" +appsearch = "1.1.0" +concurrentFuturesKtx = "1.3.0" # navigation3 nav3Core = "1.0.0-alpha10" @@ -126,6 +129,11 @@ androidx-compose-material3-adaptive-navigation3 = { group = "androidx.compose.ma androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHiltCompiler" } +androidx-appsearch = { module = "androidx.appsearch:appsearch", version.ref = "appsearch" } +androidx-appsearch-compiler = { module = "androidx.appsearch:appsearch-compiler", version.ref = "appsearch" } +androidx-appsearch-local-storage = { module = "androidx.appsearch:appsearch-local-storage", version.ref = "appsearch" } +androidx-concurrent-futures-ktx = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "concurrentFuturesKtx" } + androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } @@ -201,6 +209,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-ksp = { id = "com.google.devtools.ksp", version.ref = "androidKspPlugin" } +android-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "androidKaptPlugin" } android-hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/next/build.gradle.kts b/next/build.gradle.kts index 81ac95765..86a5681b4 100644 --- a/next/build.gradle.kts +++ b/next/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.android.ksp) + alias(libs.plugins.android.kapt) alias(libs.plugins.android.hilt) alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.plugin.serialization) @@ -88,6 +89,12 @@ dependencies { ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) + implementation(libs.androidx.appsearch) + implementation(libs.androidx.concurrent.futures.ktx) + // ksp: https://issuetracker.google.com/issues/234116803 + kapt(libs.androidx.appsearch.compiler) + implementation(libs.androidx.appsearch.local.storage) + debugImplementation(libs.androidx.compose.ui.tooling) testImplementation(libs.junit) diff --git a/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt b/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt new file mode 100644 index 000000000..1746b1fae --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt @@ -0,0 +1,49 @@ +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 androidx.appsearch.app.StringSerializer +import org.fdroid.index.IndexParser +import org.fdroid.index.v2.FileV2 + +@Document +data class AppDocument( + @Document.Namespace + val namespace: String = "app", + @Document.Id + val id: String, + @Document.CreationTimestampMillis + val lastUpdated: Long, + @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 summary: String?, + @Document.StringProperty(indexingType = INDEXING_TYPE_PREFIXES) + val description: String?, + @Document.StringProperty(indexingType = INDEXING_TYPE_PREFIXES) + val packageName: String, + @Document.StringProperty(indexingType = INDEXING_TYPE_PREFIXES) + val authorName: String?, + + @Document.StringProperty( + indexingType = INDEXING_TYPE_NONE, + serializer = FileV2Serializer::class, + ) + val icon: FileV2?, +) + +class FileV2Serializer : StringSerializer { + override fun serialize(instance: FileV2): String { + return IndexParser.json.encodeToString(instance) + } + + @Override + override fun deserialize(string: String): FileV2? { + return FileV2.deserialize(string) + } +} diff --git a/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt b/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt new file mode 100644 index 000000000..116f429a2 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt @@ -0,0 +1,157 @@ +package org.fdroid.appsearch + +import android.annotation.SuppressLint +import android.content.Context +import androidx.appsearch.app.AppSearchSession +import androidx.appsearch.app.PutDocumentsRequest +import androidx.appsearch.app.SearchSpec +import androidx.appsearch.app.SetSchemaRequest +import androidx.appsearch.localstorage.LocalStorage +import androidx.concurrent.futures.await +import androidx.core.os.LocaleListCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.fdroid.database.FDroidDatabase +import org.fdroid.utils.IoDispatcher +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppSearchManager @Inject constructor( + @ApplicationContext val context: Context, + @IoDispatcher val scope: CoroutineScope, + val db: FDroidDatabase, +) { + + private val log = KotlinLogging.logger { } + private val isInitialized: MutableStateFlow = MutableStateFlow(false) + private lateinit var session: AppSearchSession + + init { + scope.launch { + val session = LocalStorage.createSearchSessionAsync( + LocalStorage.SearchContext.Builder(context, "fdroid") + .build() + ).await() + try { + val setSchemaRequest = SetSchemaRequest.Builder() + .setForceOverride(true) + .addDocumentClasses(AppDocument::class.java) + .build() + val response = session.setSchemaAsync(setSchemaRequest).await() + response.migrationFailures.forEach { failure -> + log.warn { "Migration failure: $failure" } + } + response.incompatibleTypes.forEach { incompatible -> + log.warn { "Incompatible types: $incompatible" } + } + response.deletedTypes.forEach { deletedTypes -> + log.warn { "Deleted types: $deletedTypes" } + } + response.migratedTypes.forEach { migratedTypes -> + log.warn { "Migrated types: $migratedTypes" } + } + this@AppSearchManager.session = session + isInitialized.value = true + log.info { "Initialized." } + awaitCancellation() + } finally { + log.info { "Closing session..." } + session.close() + } + } + } + + @SuppressLint("RequiresFeature") // we only use local search which supports this + suspend fun search(s: String = "F-Droid"): List { + awaitInitialization() + val weights = mapOf( + "name" to 100.0, + "summary" to 50.0, + "description" to 10.0, + "packageName" to 5.0, + "authorName" to 1.0, + ) + // https://developer.android.com/reference/androidx/appsearch/app/SearchSpec.Builder#setRankingStrategy(java.lang.String) + val now = System.currentTimeMillis() + val weeksOldSpec = "($now - this.creationTimestamp()) / ${1000 * 60 * 60 * 24 * 7}" + val searchSpec = SearchSpec.Builder() + .setResultCountPerPage(200) + .setSnippetCount(0) + .setSnippetCountPerProperty(1) + .addFilterProperties( + AppDocument::class.java, + listOf("name", "summary", "description", "packageName", "authorName"), + ) + .setRankingStrategy("this.relevanceScore()*100-$weeksOldSpec") + .setPropertyWeightsForDocumentClass(AppDocument::class.java, weights) + .build() + return session.search(s, searchSpec).use { results -> + // we just use a single page for simplicity + val resultList = results.nextPageAsync.await() + resultList.map { r -> + r.getDocument(AppDocument::class.java) + } + } + } + + /** + * This removes all documents from the index and re-adds them back based on current DB state. + * This is done to ensure that the index is in sync with what the DB has. + * Typically, it should be called when + * * repos got updated + * * repos got disabled (it gets updated when enabled, so that is covered above) + * * the preferred repo for an app has changed + * * the device locales have changed + */ + // TODO also call this when + // * repos get disabled + // * the preferred repo for an app was changed + // * the device locale was changed + fun updateIndex() = scope.launch { + awaitInitialization() + + log.debug { "Removing all documents..." } + val removeSpec = SearchSpec.Builder().build() + session.removeAsync("", removeSpec).await() + + log.debug { "Getting all apps..." } + val localeList = LocaleListCompat.getDefault() + // TODO check if it makes more sense to do this per-repo + val docs = db.getAppDao().getAppSearchItems().map { app -> + AppDocument( + id = app.packageName, + lastUpdated = app.lastUpdated, + repoId = app.repoId, + name = app.name, + summary = app.summary, + description = app.getDescription(localeList), + packageName = app.packageName, + authorName = app.authorName, + icon = app.getIcon(localeList), + ) + } + log.debug { "Got ${docs.size} apps. Adding to appsearch.." } + val putRequest = PutDocumentsRequest.Builder() + .addDocuments(docs) + .build() + val result = session.putAsync(putRequest).await() + if (!result.isSuccess) log.error { + "Error putting documents: $result" + } + log.info { "Added ${result.successes.size} apps. Flushing to disk..." } + session.requestFlushAsync() + } + + private suspend fun awaitInitialization() { + if (!isInitialized.value) { + isInitialized.first { it } + } + } + +} diff --git a/next/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt b/next/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt index e9d97a596..cdc8d1548 100644 --- a/next/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt +++ b/next/src/main/kotlin/org/fdroid/repo/RepoUpdateManager.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.map import org.fdroid.CompatibilityChecker import org.fdroid.CompatibilityCheckerImpl import org.fdroid.NotificationManager -import org.fdroid.next.R +import org.fdroid.appsearch.AppSearchManager import org.fdroid.database.FDroidDatabase import org.fdroid.database.Repository import org.fdroid.download.DownloaderFactory @@ -20,6 +20,8 @@ import org.fdroid.index.IndexUpdateResult import org.fdroid.index.RepoManager import org.fdroid.index.RepoUpdater import org.fdroid.index.v1.IndexV1Updater +import org.fdroid.next.R +import org.fdroid.updates.UpdatesManager import java.io.File import javax.inject.Inject @@ -29,6 +31,7 @@ class RepoUpdateManager( private val context: Context, private val db: FDroidDatabase, private val repoManager: RepoManager, + private val updatesManager: UpdatesManager, private val downloaderFactory: DownloaderFactory, private val notificationManager: NotificationManager, private val compatibilityChecker: CompatibilityChecker = CompatibilityCheckerImpl( @@ -98,6 +101,7 @@ class RepoUpdateManager( listener = indexUpdateListener, ) } else null, + private val appSearchManager: AppSearchManager, ) { @Inject @@ -105,9 +109,19 @@ class RepoUpdateManager( @ApplicationContext context: Context, db: FDroidDatabase, repositoryManager: RepoManager, + updatesManager: UpdatesManager, downloaderFactory: DownloaderFactory, notificationManager: NotificationManager, - ) : this(context, db, repoManager = repositoryManager, downloaderFactory, notificationManager) + appSearchManager: AppSearchManager, + ) : this( + context = context, + db = db, + repoManager = repositoryManager, + updatesManager = updatesManager, + downloaderFactory = downloaderFactory, + notificationManager = notificationManager, + appSearchManager = appSearchManager, + ) private val isUpdateNotificationEnabled = true private val _isUpdating = MutableStateFlow(false) @@ -162,7 +176,8 @@ class RepoUpdateManager( // TODO fdroidPrefs.lastUpdateCheck = System.currentTimeMillis() if (repoErrors.isNotEmpty()) showRepoErrors(repoErrors) if (reposUpdated) { - // TODO appUpdateStatusManager.checkForUpdates(true) + updatesManager.loadUpdates() + appSearchManager.updateIndex() } } finally { notificationManager.cancelUpdateRepoNotification() @@ -177,7 +192,7 @@ class RepoUpdateManager( } val repo = repoManager.getRepository(repoId) ?: return IndexUpdateResult.NotFound _isUpdating.value = true - try { + return try { // show notification if (isUpdateNotificationEnabled) { val msg = context.getString(R.string.status_connecting_to_repo, repo.address) @@ -185,7 +200,12 @@ class RepoUpdateManager( } // indexV1Updater only gets used directly if forceIndexV1 was true - return indexV1Updater?.update(repo) ?: repoUpdater.update(repo) + val result = indexV1Updater?.update(repo) ?: repoUpdater.update(repo) + if (result is IndexUpdateResult.Processed) { + updatesManager.loadUpdates() + appSearchManager.updateIndex() + } + result } finally { notificationManager.cancelUpdateRepoNotification() _isUpdating.value = false diff --git a/next/src/main/kotlin/org/fdroid/ui/Main.kt b/next/src/main/kotlin/org/fdroid/ui/Main.kt index 159558939..25baf6a12 100644 --- a/next/src/main/kotlin/org/fdroid/ui/Main.kt +++ b/next/src/main/kotlin/org/fdroid/ui/Main.kt @@ -98,6 +98,8 @@ fun Main(onListeningForIntent: () -> Unit = {}) { onNav = { backStack.add(it) }, numUpdates = numUpdates, isBigScreen = isBigScreen, + onSearch = viewModel::search, + onSearchCleared = viewModel::onSearchCleared, modifier = Modifier, ) } @@ -190,7 +192,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) { viewModel.setVisibleRepository(it) backStack.add(NavigationKey.RepoDetails(it.repoId)) }, - onAddRepo = { }, + onAddRepo = viewModel::addRepo, ) { backStack.removeLastOrNull() } diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt index 1f5383842..15e400c2e 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -91,6 +91,8 @@ class AppDetailsViewModel @Inject constructor( updatesManager.loadUpdates() } } + + // TODO update app search when preferred repo changes } class AppInfo( diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt b/next/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt index ebec0c59f..246014fd3 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/AppSearchInputField.kt @@ -1,7 +1,6 @@ package org.fdroid.ui.discover import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear @@ -14,25 +13,40 @@ import androidx.compose.material3.SearchBarState import androidx.compose.material3.SearchBarValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import org.fdroid.next.R @Composable -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) fun AppSearchInputField( searchBarState: SearchBarState, textFieldState: TextFieldState, + onSearch: suspend (String) -> Unit, + onSearchCleared: () -> Unit, ) { val scope = rememberCoroutineScope() + // set-up search as you type + LaunchedEffect(Unit) { + snapshotFlow { textFieldState.text } + .debounce(500) + .collectLatest { + if (it.length > 2) onSearch(textFieldState.text.toString()) + } + } SearchBarDefaults.InputField( modifier = Modifier, searchBarState = searchBarState, textFieldState = textFieldState, onSearch = { - scope.launch { searchBarState.animateToCollapsed() } + scope.launch { onSearch(it) } }, placeholder = { Text(stringResource(R.string.search_placeholder)) }, leadingIcon = { @@ -51,7 +65,7 @@ fun AppSearchInputField( }, trailingIcon = { if (textFieldState.text.isNotEmpty()) { - IconButton(onClick = { textFieldState.setTextAndPlaceCursorAtEnd("") }) { + IconButton(onClick = onSearchCleared) { Icon( Icons.Filled.Clear, contentDescription = null, 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 674b2e20a..5e38447c5 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/AppsSearch.kt @@ -1,49 +1,122 @@ package org.fdroid.ui.discover +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExpandedFullScreenSearchBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarState import androidx.compose.material3.SearchBarValue +import androidx.compose.material3.Text import androidx.compose.material3.TopSearchBar import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +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.fdroid.ui.theme.FDroidContent +import org.fdroid.next.R +import org.fdroid.ui.lists.AppListItem +import org.fdroid.ui.lists.AppListRow +import org.fdroid.ui.utils.BigLoadingIndicator @Composable @OptIn(ExperimentalMaterial3Api::class) fun AppsSearch( searchBarState: SearchBarState, + searchResults: List?, + onSearch: suspend (String) -> Unit, + onItemSelected: (AppListItem) -> Unit, + onSearchCleared: () -> Unit, modifier: Modifier = Modifier, ) { - val textFieldState = rememberTextFieldState() - val inputField = @Composable { - AppSearchInputField( - searchBarState = searchBarState, - textFieldState = textFieldState, - ) - } TopSearchBar( state = searchBarState, windowInsets = WindowInsets(), - inputField = inputField, + inputField = { + // InputField is different from ExpandedFullScreenSearchBar to separate textFieldState + SearchBarDefaults.InputField( + searchBarState = searchBarState, + textFieldState = rememberTextFieldState(), + placeholder = { Text(stringResource(R.string.search_placeholder)) }, + leadingIcon = { + Icon(imageVector = Icons.Default.Search, contentDescription = null) + }, + onSearch = { }, + ) + }, modifier = modifier, ) + val textFieldState = rememberTextFieldState() ExpandedFullScreenSearchBar( state = searchBarState, - inputField = inputField, - ) { } + inputField = { + AppSearchInputField( + searchBarState = searchBarState, + textFieldState = textFieldState, + onSearch = onSearch, + onSearchCleared = { + textFieldState.setTextAndPlaceCursorAtEnd("") + onSearchCleared() + }, + ) + }, + ) { + if (searchResults == null) { + if (textFieldState.text.length >= 3) BigLoadingIndicator() + } else if (searchResults.isEmpty()) { + Text( + text = stringResource(R.string.search_no_results), + textAlign = TextAlign.Center, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(searchResults, key = { it.packageName }) { item -> + AppListRow(item, false, modifier.clickable { + onItemSelected(item) + }) + } + } + } + } } @Preview @Composable @OptIn(ExperimentalMaterial3Api::class) -fun AppsSearchPreview() { +private fun AppsSearchLoadingPreview() { FDroidContent { - val state = rememberSearchBarState(SearchBarValue.Expanded) - AppsSearch(state) + Box(Modifier.fillMaxSize()) { + val state = rememberSearchBarState(SearchBarValue.Expanded) + AppsSearch(state, null, {}, {}, {}) + } + } +} + +@Preview +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun AppsSearchEmptyPreview() { + FDroidContent { + Box(Modifier.fillMaxSize()) { + val state = rememberSearchBarState(SearchBarValue.Expanded) + AppsSearch(state, 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 03ada10e3..42b01ebf9 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt @@ -50,10 +50,12 @@ fun Discover( discoverModel: DiscoverModel, numUpdates: Int, isBigScreen: Boolean, - modifier: Modifier = Modifier, + onSearch: suspend (String) -> Unit, + onSearchCleared: () -> Unit, onListTap: (AppListType) -> Unit, onAppTap: (AppDiscoverItem) -> Unit, onNav: (NavKey) -> Unit, + modifier: Modifier = Modifier, ) { val searchBarState = rememberSearchBarState() val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) @@ -116,6 +118,12 @@ fun Discover( is LoadedDiscoverModel -> { AppsSearch( searchBarState = searchBarState, + searchResults = discoverModel.searchResults, + onSearch = onSearch, + onItemSelected = { + onNav(NavigationKey.AppDetails(it.packageName)) + }, + onSearchCleared = onSearchCleared, modifier = Modifier.padding(top = 8.dp), ) val listNew = AppListType.New(stringResource(R.string.app_list_new)) @@ -170,6 +178,8 @@ fun LoadingDiscoverPreview() { onListTap = {}, onAppTap = {}, onNav = {}, + onSearch = {}, + onSearchCleared = {}, ) } } 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 69b1407cf..9096ddd85 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,21 @@ 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 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?>, ): DiscoverModel { val apps = appsFlow.collectAsState(null).value val categories = categoriesFlow.collectAsState(null).value + val searchResults = searchResultsFlow.collectAsState().value return if (apps.isNullOrEmpty()) { val repositories = repositoriesFlow.collectAsState(null).value @@ -32,6 +36,7 @@ fun DiscoverPresenter( newApps = apps.filter { it.isNew }, recentlyUpdatedApps = apps.filter { !it.isNew }, categories = categories, + searchResults = searchResults, ) } } @@ -43,4 +48,5 @@ data class LoadedDiscoverModel( val newApps: List, val recentlyUpdatedApps: List, val categories: List?, + val searchResults: List? = 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 abb1c02da..56ef50fd6 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt @@ -11,13 +11,18 @@ import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.fdroid.appsearch.AppSearchManager import org.fdroid.database.FDroidDatabase import org.fdroid.download.getDownloadRequest import org.fdroid.index.RepoManager import org.fdroid.ui.categories.CategoryItem +import org.fdroid.ui.lists.AppListItem import org.fdroid.updates.UpdatesManager +import org.fdroid.utils.IoDispatcher import java.text.Collator import java.util.Locale import javax.inject.Inject @@ -29,6 +34,8 @@ class DiscoverViewModel @Inject constructor( db: FDroidDatabase, updatesManager: UpdatesManager, private val repoManager: RepoManager, + private val appSearchManager: AppSearchManager, + @IoDispatcher private val ioScope: CoroutineScope, ) : AndroidViewModel(app) { private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) @@ -57,6 +64,7 @@ class DiscoverViewModel @Inject constructor( ) }.sortedWith { c1, c2 -> collator.compare(c1.name, c2.name) } } + private val searchResults = MutableStateFlow?>(null) val localeList = LocaleListCompat.getDefault() val discoverModel: StateFlow = scope.launchMolecule(mode = ContextClock) { @@ -64,7 +72,25 @@ class DiscoverViewModel @Inject constructor( appsFlow = apps, categoriesFlow = categories, repositoriesFlow = repoManager.repositoriesState, + searchResultsFlow = searchResults, ) } + 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), + ) + } + } + + fun onSearchCleared() { + searchResults.value = null + } } diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt index 90c367115..cd0ade4cd 100644 --- a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt @@ -41,4 +41,9 @@ class RepositoriesViewModel @Inject constructor( _visibleRepositoryItem.value = repositoryItem } + fun addRepo() { + } + + // TODO update appsearch when repo got disabled + } diff --git a/next/src/main/res/values/strings-next.xml b/next/src/main/res/values/strings-next.xml index 3c174bb0b..4e108e663 100644 --- a/next/src/main/res/values/strings-next.xml +++ b/next/src/main/res/values/strings-next.xml @@ -19,6 +19,7 @@ Apps by %s Search… + No apps found.\n\nTry to use less search terms or add/enable more repositories. Sort by name Sort by latest Category