Initial implementation of appsearch

This commit is contained in:
Torsten Grote
2025-07-25 17:46:09 -03:00
parent e45e81da7f
commit 1339acf5b7
15 changed files with 406 additions and 24 deletions

View File

@@ -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

View File

@@ -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" }

View File

@@ -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)

View File

@@ -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<FileV2> {
override fun serialize(instance: FileV2): String {
return IndexParser.json.encodeToString(instance)
}
@Override
override fun deserialize(string: String): FileV2? {
return FileV2.deserialize(string)
}
}

View File

@@ -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<Boolean> = 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<AppDocument> {
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 }
}
}
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -91,6 +91,8 @@ class AppDetailsViewModel @Inject constructor(
updatesManager.loadUpdates()
}
}
// TODO update app search when preferred repo changes
}
class AppInfo(

View File

@@ -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,

View File

@@ -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<AppListItem>?,
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(), {}, {}, {})
}
}
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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<List<AppDiscoverItem>>,
categoriesFlow: Flow<List<CategoryItem>>,
repositoriesFlow: Flow<List<Repository>>,
searchResultsFlow: MutableStateFlow<List<AppListItem>?>,
): 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<AppDiscoverItem>,
val recentlyUpdatedApps: List<AppDiscoverItem>,
val categories: List<CategoryItem>?,
val searchResults: List<AppListItem>? = null,
) : DiscoverModel()

View File

@@ -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<List<AppListItem>?>(null)
val localeList = LocaleListCompat.getDefault()
val discoverModel: StateFlow<DiscoverModel> = 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
}
}

View File

@@ -41,4 +41,9 @@ class RepositoriesViewModel @Inject constructor(
_visibleRepositoryItem.value = repositoryItem
}
fun addRepo() {
}
// TODO update appsearch when repo got disabled
}

View File

@@ -19,6 +19,7 @@
<string name="app_list_author">Apps by %s</string>
<string name="search_placeholder">Search…</string>
<string name="search_no_results">No apps found.\n\nTry to use less search terms or add/enable more repositories.</string>
<string name="sort_by_name">Sort by name</string>
<string name="sort_by_latest">Sort by latest</string>
<string name="category">Category</string>