Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,11 @@ class AllAppsSearchInput(context: Context, attrs: AttributeSet?) :
if (input.text.isNullOrEmpty() && input.hasFocus() && !input.isResetting) {
searchAlgorithm?.doZeroStateSearch(this)
}
if (input.text.isNullOrEmpty() && input.isResetting) {
// Back with a query resets the box without resetSearch(); end the typing
// session here too so the next search does not start from these results.
searchAlgorithm?.cancel(true)
}
if (input.text.toString() == "/lawnchairdebug") {
val enableDebugMenu = prefs.enableDebugMenu
enableDebugMenu.set(!enableDebugMenu.get())
Expand Down Expand Up @@ -491,6 +496,8 @@ class AllAppsSearchInput(context: Context, attrs: AttributeSet?) :
}

override fun resetSearch() {
// Closing search ends the typing session, so the next query starts without old results.
searchAlgorithm?.cancel(true)
searchBarController.reset()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,13 @@ import com.android.launcher3.LauncherAppState
import com.android.launcher3.R
import com.android.launcher3.allapps.BaseAllAppsAdapter
import com.android.launcher3.search.SearchCallback
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

Expand All @@ -63,51 +62,71 @@ class LawnchairLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgorithm
WebSuggestionProvider,
)

private val nonAppProviderResults = SeededProviderResults(searchProviders)

// Bumped when a search session ends (cancel(true), zero state). A search queued on the model
// thread before that must not run afterwards, or its results would reach the next session.
private val session = AtomicInteger()

// Guards currentJob and the session check, so a session can't end between a model-thread
// search checking the session and launching its job.
private val jobLock = Any()

override fun doSearch(query: String, callback: SearchCallback<BaseAllAppsAdapter.AdapterItem>) {
val requestSession = session.get()
appState.model.enqueueModelUpdateTask { _, _, apps ->
if (session.get() != requestSession) return@enqueueModelUpdateTask
val appResults = appSearchProvider.search(context, query, apps)
val shortcutResults = shortcutSearchProvider.search(context, appResults)

currentJob?.cancel()
currentJob = coroutineScope.launch {
val calcResult = CalculatorSearchProvider.search(context, query)
.firstOrNull()
.orEmpty()
val actionResults = generateActionResults(query)

// Seed every provider with an empty result so apps, shortcuts and calculations
// show as soon as they are ready. Without the seed, combine() waits for the
// slowest provider (web suggestions go over the network) before the first frame.
val nonAppProvidersFlow = combine(
searchProviders.map { it.search(context, query).onStart { emit(emptyList()) } },
) { resultsArray ->
resultsArray.toList().flatten()
}

nonAppProvidersFlow.collectLatest { nonAppResults ->
val allResults = appResults + shortcutResults + calcResult + nonAppResults + actionResults
synchronized(jobLock) {
if (session.get() != requestSession) return@enqueueModelUpdateTask
currentJob?.cancel()
currentJob = launchSearch(query, requestSession, appResults, shortcutResults, callback)
}
}
}

val searchTargets = translateToSearchTargets(query, allResults)
setFirstItemQuickLaunch(searchTargets)
val adapterItems = transformSearchResults(searchTargets)
withContext(Dispatchers.Main) {
callback.onSearchResult(query, ArrayList(adapterItems))
}
private fun launchSearch(
query: String,
requestSession: Int,
appResults: List<SearchResult>,
shortcutResults: List<SearchResult>,
callback: SearchCallback<BaseAllAppsAdapter.AdapterItem>,
): Job = coroutineScope.launch {
val calcResult = CalculatorSearchProvider.search(context, query)
.firstOrNull()
.orEmpty()
val actionResults = generateActionResults(query)

// Seed every provider with what it returned for the previous query so apps,
// shortcuts and calculations show as soon as they are ready while the other
// sections keep their rows until their provider answers. Without the seed,
// combine() waits for the slowest provider (web suggestions go over the network).
nonAppProviderResults.search(context, query).collectLatest { nonAppResults ->
val allResults = appResults + shortcutResults + calcResult + nonAppResults + actionResults

val searchTargets = translateToSearchTargets(query, allResults)
setFirstItemQuickLaunch(searchTargets)
val adapterItems = transformSearchResults(searchTargets)
withContext(Dispatchers.Main) {
if (session.get() == requestSession) {
callback.onSearchResult(query, ArrayList(adapterItems))
}
}
}
}

override fun doZeroStateSearch(callback: SearchCallback<BaseAllAppsAdapter.AdapterItem>) {
currentJob?.cancel()
endSession()

val prefs = PreferenceManager.getInstance(context)
val historyEnabled = prefs.searchResulRecentSuggestion.get()

if (!historyEnabled) {
callback.clearSearchResult()
} else {
currentJob = coroutineScope.launch {
val historyJob = coroutineScope.launch {
val prefs2 = PreferenceManager2.getInstance(context)
val maxHistory = prefs2.maxRecentResultCount.firstCached()

Expand All @@ -131,11 +150,25 @@ class LawnchairLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgorithm
callback.onSearchResult("", ArrayList(adapterItems))
}
}
synchronized(jobLock) { currentJob = historyJob }
}
}

override fun cancel(interruptActiveRequests: Boolean) {
currentJob?.cancel()
// Typing cancels with false before each new query; true ends the search session.
if (interruptActiveRequests) {
endSession()
} else {
synchronized(jobLock) { currentJob?.cancel() }
}
}

private fun endSession() {
synchronized(jobLock) {
session.incrementAndGet()
currentJob?.cancel()
}
nonAppProviderResults.clear()
}

private fun generateActionResults(query: String): List<SearchResult.Action> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import app.lawnchair.allapps.views.SearchResultView.Companion.EXTRA_QUICK_LAUNCH
import app.lawnchair.preferences2.PreferenceManager2
import app.lawnchair.preferences2.firstCached
import app.lawnchair.search.LawnchairSearchAdapterProvider
import app.lawnchair.search.adapter.MARKET_STORE
import app.lawnchair.search.adapter.START_PAGE
import app.lawnchair.search.adapter.SearchAdapterItem
import app.lawnchair.search.adapter.SearchTargetCompat
Expand Down Expand Up @@ -124,20 +125,6 @@ sealed class LawnchairSearchAlgorithm(
}
}

protected fun setFirstItemQuickLaunch(searchTargets: List<SearchTargetCompat>) {
val hasQuickLaunch = searchTargets.any { it.extras.getBoolean(EXTRA_QUICK_LAUNCH, false) }
if (!hasQuickLaunch) {
// check if we have a header or spacer item. if so, we skip as there isn't any relevant
// action to be applied
val target = searchTargets.getOrNull(
searchTargets.indexOfFirst { it.layoutType != TEXT_HEADER },
)
target?.extras?.apply {
putBoolean(EXTRA_QUICK_LAUNCH, true)
}
}
}

private fun findIndices(filtered: List<SearchTargetCompat>, layoutType: String): List<Int> {
return filtered.indices.filter {
filtered[it].layoutType == layoutType && !filtered[it].isApp
Expand Down Expand Up @@ -252,6 +239,26 @@ sealed class LawnchairSearchAlgorithm(
}
}

// Top level so the quick-launch choice can be unit tested without building a search algorithm.
internal fun setFirstItemQuickLaunch(searchTargets: List<SearchTargetCompat>) {
val hasQuickLaunch = searchTargets.any { it.extras.getBoolean(EXTRA_QUICK_LAUNCH, false) }
if (!hasQuickLaunch) {
// Web suggestions arrive after the other rows, so the Play Store row can briefly be the
// first one. Skip it while a web search row is shown so Enter does not flip between them.
val skipMarketSearch = searchTargets.any { it.packageName == START_PAGE }
// check if we have a header or spacer item. if so, we skip as there isn't any relevant
// action to be applied
val target = searchTargets.getOrNull(
searchTargets.indexOfFirst {
it.layoutType != TEXT_HEADER && !(skipMarketSearch && it.packageName == MARKET_STORE)
},
)
target?.extras?.apply {
putBoolean(EXTRA_QUICK_LAUNCH, true)
}
}
}

private fun Sequence<SearchTargetCompat>.removeDuplicateDividers(): Sequence<SearchTargetCompat> {
var previousWasDivider = true
return filter { item ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package app.lawnchair.search.algorithms

import android.content.Context
import app.lawnchair.search.algorithms.engine.SearchProvider
import app.lawnchair.search.algorithms.engine.SearchResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow

/**
* Combines the results of [providers] without waiting for the slowest one.
*
* Each provider starts from the results it returned for the previous query of the same typing
* session, so its section keeps its rows while the provider works on the new query instead of
* vanishing and reappearing on every keystroke. The provider's answer to the new query replaces
* those rows as soon as it arrives. [clear] ends the typing session.
*/
internal class SeededProviderResults(private val providers: List<SearchProvider>) {

private val lock = Any()
private val lastResults = mutableMapOf<String, List<SearchResult>>()

// Bumped by every search and clear, so answers to an older query or to an ended session are
// never kept as seeds.
private var generation = 0

fun search(context: Context, query: String): Flow<List<SearchResult>> {
val (searchGeneration, seeds) = synchronized(lock) { ++generation to lastResults.toMap() }
return combine(
providers.map { it.seededSearch(context, query, seeds, searchGeneration) },
) { resultsArray ->
resultsArray.toList().flatten()
}.distinctUntilChanged()
}

fun clear() {
synchronized(lock) {
generation++
lastResults.clear()
}
}

private fun SearchProvider.seededSearch(
context: Context,
query: String,
seeds: Map<String, List<SearchResult>>,
searchGeneration: Int,
): Flow<List<SearchResult>> = flow {
emit(seeds[id].orEmpty())
var answered = false
search(context, query).collect { results ->
answered = true
remember(id, results, searchGeneration)
emit(results)
}
// A provider that finishes without answering has nothing for this query.
if (!answered) {
remember(id, emptyList(), searchGeneration)
emit(emptyList())
}
}

private fun remember(id: String, results: List<SearchResult>, searchGeneration: Int) {
synchronized(lock) {
if (searchGeneration == generation) lastResults[id] = results
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
package app.lawnchair.search.algorithms.data.calculator

import app.lawnchair.search.algorithms.data.calculator.internal.AssignExpr
import app.lawnchair.search.algorithms.data.calculator.internal.BinaryExpr
import app.lawnchair.search.algorithms.data.calculator.internal.CallExpr
import app.lawnchair.search.algorithms.data.calculator.internal.Evaluator
import app.lawnchair.search.algorithms.data.calculator.internal.Expr
import app.lawnchair.search.algorithms.data.calculator.internal.Function
import app.lawnchair.search.algorithms.data.calculator.internal.GroupingExpr
import app.lawnchair.search.algorithms.data.calculator.internal.LiteralExpr
import app.lawnchair.search.algorithms.data.calculator.internal.LogicalExpr
import app.lawnchair.search.algorithms.data.calculator.internal.Parser
import app.lawnchair.search.algorithms.data.calculator.internal.Scanner
import app.lawnchair.search.algorithms.data.calculator.internal.Token
import app.lawnchair.search.algorithms.data.calculator.internal.TokenType.SQUARE_ROOT
import app.lawnchair.search.algorithms.data.calculator.internal.UnaryExpr
import app.lawnchair.search.algorithms.data.calculator.internal.VariableExpr
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
Expand All @@ -30,6 +39,16 @@ class Expressions {
arguments.first().abs()
}

evaluator.addFunction("sqrt") { arguments ->
if (arguments.size != 1) {
throw ExpressionException(
"sqrt requires one argument",
)
}

arguments.first().sqrt(evaluator.mathContext)
}

evaluator.addFunction("sum") { arguments ->
if (arguments.isEmpty()) {
throw ExpressionException(
Expand Down Expand Up @@ -167,6 +186,25 @@ class Expressions {
return evaluator.eval(parse(expression))
}

/**
* Whether [expression] actually calculates something: it contains a digit and applies an
* operator (other than a leading sign) or a function to it. A bare number or constant such as
* `2024`, `-5` or `pi` is not a calculation.
*/
fun isCalculation(expression: String): Boolean {
return expression.any(Char::isDigit) && parse(expression).hasOperation()
}

private fun Expr.hasOperation(): Boolean {
return when (this) {
is BinaryExpr, is LogicalExpr, is CallExpr -> true
is UnaryExpr -> operator.type == SQUARE_ROOT || right.hasOperation()
is GroupingExpr -> expression.hasOperation()
is AssignExpr -> value.hasOperation()
is LiteralExpr, is VariableExpr -> false
}
}

/**
* eval an expression then round it with {@link Evaluator#mathContext} and call toEngineeringString <br>
* if error will return message from Throwable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.lawnchair.search.algorithms.engine.provider

import android.content.Context
import androidx.annotation.VisibleForTesting
import app.lawnchair.preferences.PreferenceManager
import app.lawnchair.search.algorithms.data.Calculation
import app.lawnchair.search.algorithms.data.calculator.Expressions
Expand Down Expand Up @@ -36,11 +37,16 @@ object CalculatorSearchProvider : SearchProvider {
}
}

private fun calculateEquationFromString(
@VisibleForTesting
internal fun calculateEquationFromString(
query: String,
): Calculation {
return try {
val evaluatedValue = Expressions().eval(query)
val expressions = Expressions()
// A bare number or constant ("2024", "e", "pi") would otherwise show a calculator row
// that becomes the Enter target when no app matches.
require(expressions.isCalculation(query)) { "Not a calculation" }
val evaluatedValue = expressions.eval(query)
val roundedValue = evaluatedValue.round(MathContext.DECIMAL64)
val formattedValue = roundedValue.stripTrailingZeros()
val absoluteValue = formattedValue.abs()
Expand Down
Loading
Loading