mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-05-24 00:14:43 -04:00
Initial implementation of appsearch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt
Normal file
49
next/src/main/kotlin/org/fdroid/appsearch/AppDocument.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
157
next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt
Normal file
157
next/src/main/kotlin/org/fdroid/appsearch/AppSearchManager.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ class AppDetailsViewModel @Inject constructor(
|
||||
updatesManager.loadUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO update app search when preferred repo changes
|
||||
}
|
||||
|
||||
class AppInfo(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(), {}, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,9 @@ class RepositoriesViewModel @Inject constructor(
|
||||
_visibleRepositoryItem.value = repositoryItem
|
||||
}
|
||||
|
||||
fun addRepo() {
|
||||
}
|
||||
|
||||
// TODO update appsearch when repo got disabled
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user