diff --git a/android/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt b/android/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt index e56ece54834..d3f3a9447eb 100644 --- a/android/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt +++ b/android/lawnchair/src/app/lawnchair/allapps/AllAppsSearchInput.kt @@ -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()) @@ -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() } diff --git a/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairLocalSearchAlgorithm.kt b/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairLocalSearchAlgorithm.kt index eb2812dce71..8fbf6833d2a 100644 --- a/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairLocalSearchAlgorithm.kt +++ b/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairLocalSearchAlgorithm.kt @@ -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 @@ -63,43 +62,63 @@ 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) { + 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, + shortcutResults: List, + callback: SearchCallback, + ): 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) { - currentJob?.cancel() + endSession() val prefs = PreferenceManager.getInstance(context) val historyEnabled = prefs.searchResulRecentSuggestion.get() @@ -107,7 +126,7 @@ class LawnchairLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgorithm if (!historyEnabled) { callback.clearSearchResult() } else { - currentJob = coroutineScope.launch { + val historyJob = coroutineScope.launch { val prefs2 = PreferenceManager2.getInstance(context) val maxHistory = prefs2.maxRecentResultCount.firstCached() @@ -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 { diff --git a/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairSearchAlgorithm.kt b/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairSearchAlgorithm.kt index 1212437cc0b..b28d529de60 100644 --- a/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairSearchAlgorithm.kt +++ b/android/lawnchair/src/app/lawnchair/search/algorithms/LawnchairSearchAlgorithm.kt @@ -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 @@ -124,20 +125,6 @@ sealed class LawnchairSearchAlgorithm( } } - protected fun setFirstItemQuickLaunch(searchTargets: List) { - 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, layoutType: String): List { return filtered.indices.filter { filtered[it].layoutType == layoutType && !filtered[it].isApp @@ -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) { + 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.removeDuplicateDividers(): Sequence { var previousWasDivider = true return filter { item -> diff --git a/android/lawnchair/src/app/lawnchair/search/algorithms/SeededProviderResults.kt b/android/lawnchair/src/app/lawnchair/search/algorithms/SeededProviderResults.kt new file mode 100644 index 00000000000..d4b1fc26387 --- /dev/null +++ b/android/lawnchair/src/app/lawnchair/search/algorithms/SeededProviderResults.kt @@ -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) { + + private val lock = Any() + private val lastResults = mutableMapOf>() + + // 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> { + 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>, + searchGeneration: Int, + ): Flow> = 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, searchGeneration: Int) { + synchronized(lock) { + if (searchGeneration == generation) lastResults[id] = results + } + } +} diff --git a/android/lawnchair/src/app/lawnchair/search/algorithms/data/calculator/Expressions.kt b/android/lawnchair/src/app/lawnchair/search/algorithms/data/calculator/Expressions.kt index a0817c37023..e8f6dcac36f 100644 --- a/android/lawnchair/src/app/lawnchair/search/algorithms/data/calculator/Expressions.kt +++ b/android/lawnchair/src/app/lawnchair/search/algorithms/data/calculator/Expressions.kt @@ -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 @@ -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( @@ -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
* if error will return message from Throwable diff --git a/android/lawnchair/src/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProvider.kt b/android/lawnchair/src/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProvider.kt index 959882f3f2e..3d9e3258379 100644 --- a/android/lawnchair/src/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProvider.kt +++ b/android/lawnchair/src/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProvider.kt @@ -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 @@ -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() diff --git a/android/src/com/android/launcher3/InvariantDeviceProfile.java b/android/src/com/android/launcher3/InvariantDeviceProfile.java index 59c56a92218..8a8e1ea660f 100644 --- a/android/src/com/android/launcher3/InvariantDeviceProfile.java +++ b/android/src/com/android/launcher3/InvariantDeviceProfile.java @@ -446,7 +446,9 @@ private void initGrid(Context context, Info displayInfo, DisplayOption displayOp enableTwoLinesInAllApps = Flags.enableTwolineToggle() && Utilities.isEnglishLanguage(context) && mPrefs.get(ENABLE_TWOLINE_ALLAPPS_TOGGLE); - mLocale = context.getResources().getConfiguration().locale.toString(); + // The whole list, not just the primary locale: app labels fall back through it and the + // drawer's section index is built from it. + mLocale = context.getResources().getConfiguration().getLocales().toLanguageTags(); DeviceProfileOverrides.Options overrideOptions = DeviceProfileOverrides.INSTANCE.get(context) .getOverrides(displayOption.grid, displayInfo.getDeviceType(), previewOverrides); @@ -625,7 +627,10 @@ public void setCurrentGrid(Context context, String newGridName) { private Object[] toModelState() { return new Object[]{ numColumns, numRows, numSearchContainerColumns, numDatabaseHotseatIcons, - iconBitmapSize, allAppsIconBitmapSize, fillResIconDpi, numDatabaseAllAppsColumns, dbFile}; + iconBitmapSize, allAppsIconBitmapSize, fillResIconDpi, numDatabaseAllAppsColumns, dbFile, + // A language change must reload the model so app labels and drawer sections + // follow the new locale. + mLocale}; } /** Updates IDP using the provided context. Notifies listeners of change. */ diff --git a/android/tests/expressiveUnit/app/lawnchair/search/algorithms/SearchQuickLaunchTargetTest.kt b/android/tests/expressiveUnit/app/lawnchair/search/algorithms/SearchQuickLaunchTargetTest.kt new file mode 100644 index 00000000000..7ca7e4c8a42 --- /dev/null +++ b/android/tests/expressiveUnit/app/lawnchair/search/algorithms/SearchQuickLaunchTargetTest.kt @@ -0,0 +1,158 @@ +package app.lawnchair.search.algorithms + +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Process +import androidx.test.core.app.ApplicationProvider +import app.lawnchair.allapps.views.SearchResultView.Companion.EXTRA_QUICK_LAUNCH +import app.lawnchair.search.adapter.MARKET_STORE +import app.lawnchair.search.adapter.START_PAGE +import app.lawnchair.search.adapter.SearchTargetCompat +import app.lawnchair.search.adapter.SearchTargetFactory +import app.lawnchair.search.adapter.WEB_SUGGESTION +import app.lawnchair.search.algorithms.engine.ActionsSectionBuilder +import app.lawnchair.search.algorithms.engine.AppsAndShortcutsSectionBuilder +import app.lawnchair.search.algorithms.engine.SearchResult +import app.lawnchair.search.algorithms.engine.WebSuggestionsSectionBuilder +import app.lawnchair.search.algorithms.engine.provider.web.GoogleWebSearchProvider +import app.lawnchair.theme.ThemeProvider +import app.lawnchair.theme.color.SystemColorScheme +import com.android.launcher3.model.data.AppInfo +import com.google.common.truth.Truth.assertThat +import dev.kdrag0n.monet.theme.ColorScheme +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +/** + * The quick-launch row is what Enter / IME Go opens from the drawer search box. Local search + * renders apps before web suggestions arrive, so the chosen row must not flip between the Play + * Store and the web while suggestions load. + */ +@RunWith(RobolectricTestRunner::class) +@Config( + sdk = [37], + application = Application::class, + shadows = [SearchQuickLaunchTargetTest.ShadowThemeProvider::class], +) +class SearchQuickLaunchTargetTest { + + private val context = ApplicationProvider.getApplicationContext() + private val factory by lazy { SearchTargetFactory(context) } + + @Before + fun installPlayStore() { + val store = ComponentName(STORE_PACKAGE, "$STORE_PACKAGE.SearchActivity") + shadowOf(context.packageManager).apply { + addActivityIfNotPresent(store) + addIntentFilterForActivity( + store, + IntentFilter(Intent.ACTION_VIEW).apply { addDataScheme("market") }, + ) + addIntentFilterForActivity( + store, + IntentFilter(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }, + ) + } + // Without a resolvable store the Play Store row is never built and the test proves nothing. + assertThat(factory.createMarketSearchTarget(QUERY)).isNotNull() + } + + @Test + fun noAppMatch_whileWebSuggestionsLoad_enterOpensWebSearchNotPlayStore() { + val targets = localSearchTargets(actionResults()) + + setFirstItemQuickLaunch(targets) + + assertThat(targets.quickLaunchPackages()).containsExactly(START_PAGE) + } + + @Test + fun noAppMatch_afterWebSuggestionsArrive_enterOpensFirstSuggestion() { + val suggestions = listOf( + SearchResult.WebSuggestion("$QUERY today", GoogleWebSearchProvider.id), + SearchResult.WebSuggestion("$QUERY tomorrow", GoogleWebSearchProvider.id), + ) + val targets = localSearchTargets(suggestions + actionResults()) + + setFirstItemQuickLaunch(targets) + + assertThat(targets.quickLaunchPackages()).containsExactly(WEB_SUGGESTION) + assertThat(targets.single { it.isQuickLaunch }.searchAction?.title).isEqualTo("$QUERY today") + } + + @Test + fun appMatch_staysEnterTarget() { + val app = AppInfo().apply { + componentName = ComponentName(APP_PACKAGE, "$APP_PACKAGE.MainActivity") + user = Process.myUserHandle() + } + val targets = localSearchTargets(listOf(SearchResult.App(app)) + actionResults()) + + setFirstItemQuickLaunch(targets) + + assertThat(targets.quickLaunchPackages()).containsExactly(APP_PACKAGE) + } + + @Test + fun withoutWebSearchAction_playStoreStaysEnterTarget() { + // App-only search, or local search with the web action turned off, has no web row. + val targets = localSearchTargets(listOf(SearchResult.Action.MarketSearch(QUERY))) + + setFirstItemQuickLaunch(targets) + + assertThat(targets.quickLaunchPackages()).containsExactly(MARKET_STORE) + } + + /** The actions LawnchairLocalSearchAlgorithm.generateActionResults() adds, in its order. */ + private fun actionResults(): List = listOf( + SearchResult.Action.WebSearch( + query = QUERY, + providerName = context.getString(GoogleWebSearchProvider.label), + searchUrl = GoogleWebSearchProvider.getSearchUrl(QUERY), + providerIconRes = GoogleWebSearchProvider.iconRes, + ), + SearchResult.Action.MarketSearch(query = QUERY), + SearchResult.Action.SearchSettings, + ) + + /** The local-search sections that hold rows in these scenarios, in the algorithm's order. */ + private fun localSearchTargets(results: List): List = + listOf(AppsAndShortcutsSectionBuilder, WebSuggestionsSectionBuilder, ActionsSectionBuilder) + .flatMap { it.build(context, factory, results) } + + private val SearchTargetCompat.isQuickLaunch + get() = extras.getBoolean(EXTRA_QUICK_LAUNCH, false) + + private fun List.quickLaunchPackages() = + filter { it.isQuickLaunch }.map { it.packageName } + + // Header and suggestion rows tint their icons from the theme. The bare test application does + // not initialize launcher preferences or wallpaper-derived colors. + @Implements(ThemeProvider::class) + class ShadowThemeProvider { + private lateinit var context: Context + + @Implementation + fun __constructor__(context: Context) { + this.context = context + } + + @Implementation + fun getColorScheme(): ColorScheme = SystemColorScheme(context) + } + + private companion object { + const val QUERY = "weather" + const val STORE_PACKAGE = "com.android.vending" + const val APP_PACKAGE = "com.example.weather" + } +} diff --git a/android/tests/expressiveUnit/app/lawnchair/search/algorithms/SeededProviderResultsTest.kt b/android/tests/expressiveUnit/app/lawnchair/search/algorithms/SeededProviderResultsTest.kt new file mode 100644 index 00000000000..25f50b9898e --- /dev/null +++ b/android/tests/expressiveUnit/app/lawnchair/search/algorithms/SeededProviderResultsTest.kt @@ -0,0 +1,187 @@ +package app.lawnchair.search.algorithms + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.lawnchair.search.algorithms.data.SettingInfo +import app.lawnchair.search.algorithms.engine.SearchProvider +import app.lawnchair.search.algorithms.engine.SearchResult +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [37], application = Application::class) +class SeededProviderResultsTest { + + private val context = ApplicationProvider.getApplicationContext() + private val web = FakeProvider("web") + private val settings = FakeProvider("settings") + private val results = SeededProviderResults(listOf(web, settings)) + + @Test + fun firstQuery_showsAnsweredSectionsWithoutWaitingForSlowProviders() = runBlocking { + val emissions = search("a") + assertThat(emissions.next()).isEmpty() + + settings.answer("a", setting("a")) + assertThat(emissions.next()).containsExactly(setting("a")) + emissions.stop() + } + + @Test + fun nextQuery_keepsEachSectionUntilItsProviderAnswers() = runBlocking { + showAnswers("a") + + val emissions = search("ab") + assertThat(emissions.next()).containsExactly(suggestion("a"), setting("a")).inOrder() + + web.answer("ab", suggestion("ab")) + assertThat(emissions.next()).containsExactly(suggestion("ab"), setting("a")).inOrder() + + settings.answer("ab") + assertThat(emissions.next()).containsExactly(suggestion("ab")) + emissions.stop() + } + + @Test + fun providerFinishingWithoutAnswer_dropsThePreviousQueryResults() = runBlocking { + showAnswers("a") + + val emissions = search("ab") + assertThat(emissions.next()).containsExactly(suggestion("a"), setting("a")).inOrder() + + web.finishWithoutAnswer("ab") + assertThat(emissions.next()).containsExactly(setting("a")) + emissions.stop() + } + + @Test + fun identicalAnswer_isNotEmittedAgain() = runBlocking { + showAnswers("a") + + val emissions = search("ab") + assertThat(emissions.next()).containsExactly(suggestion("a"), setting("a")).inOrder() + + web.answer("ab", suggestion("a")) + // Let the identical answer reach the output before the next one can be batched with it. + repeat(10) { yield() } + settings.answer("ab", setting("ab")) + assertThat(emissions.next()).containsExactly(suggestion("a"), setting("ab")).inOrder() + emissions.stop() + } + + @Test + fun clear_startsTheNextQueryEmpty() = runBlocking { + showAnswers("a") + results.clear() + + val emissions = search("ab") + assertThat(emissions.next()).isEmpty() + emissions.stop() + } + + @Test + fun clear_ignoresAnswersThatArriveForTheEndedSession() = runBlocking { + val ended = search("a") + assertThat(ended.next()).isEmpty() + + results.clear() + web.answer("a", suggestion("a")) + settings.answer("a", setting("a")) + ended.awaitAnswers("a") + ended.stop() + + val emissions = search("ab") + assertThat(emissions.next()).isEmpty() + emissions.stop() + } + + @Test + fun lateAnswerForOlderQuery_doesNotReplaceNewerQueryResults() = runBlocking { + showAnswers("a") + + val older = search("ab") + assertThat(older.next()).containsExactly(suggestion("a"), setting("a")).inOrder() + showAnswers("abc") + + web.answer("ab", suggestion("ab")) + settings.answer("ab", setting("ab")) + older.awaitAnswers("ab") + older.stop() + + val emissions = search("abcd") + assertThat(emissions.next()).containsExactly(suggestion("abc"), setting("abc")).inOrder() + emissions.stop() + } + + /** Runs [query] until both providers have answered it and their results are shown. */ + private suspend fun CoroutineScope.showAnswers(query: String) { + val emissions = search(query) + web.answer(query, suggestion(query)) + settings.answer(query, setting(query)) + emissions.awaitAnswers(query) + emissions.stop() + } + + private suspend fun Emissions.awaitAnswers(query: String) { + val answered = listOf(suggestion(query), setting(query)) + do { + val shown = next() + } while (shown != answered) + } + + private fun CoroutineScope.search(query: String): Emissions { + val channel = Channel>(Channel.UNLIMITED) + val job = launch { results.search(context, query).collect { channel.send(it) } } + return Emissions(channel, job) + } + + private class Emissions( + private val channel: Channel>, + private val job: Job, + ) { + suspend fun next(): List = withTimeout(5_000) { channel.receive() } + + suspend fun stop() = job.cancelAndJoin() + } + + /** Answers each query only when the test says so, like a provider waiting on I/O. */ + private class FakeProvider(override val id: String) : SearchProvider { + private val answers = ConcurrentHashMap?>>() + + fun answer(query: String, vararg results: SearchResult) { + answerFor(query).complete(results.toList()) + } + + fun finishWithoutAnswer(query: String) { + answerFor(query).complete(null) + } + + private fun answerFor(query: String) = answers.getOrPut(query) { CompletableDeferred() } + + override fun search(context: Context, query: String): Flow> = flow { + answerFor(query).await()?.let { emit(it) } + } + } + + private fun suggestion(query: String) = + SearchResult.WebSuggestion(suggestion = "$query suggestion", provider = "web") + + private fun setting(query: String) = + SearchResult.Setting(SettingInfo(id = query, name = "$query setting", action = query)) +} diff --git a/android/tests/expressiveUnit/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProviderTest.kt b/android/tests/expressiveUnit/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProviderTest.kt new file mode 100644 index 00000000000..3534d5fbfd5 --- /dev/null +++ b/android/tests/expressiveUnit/app/lawnchair/search/algorithms/engine/provider/CalculatorSearchProviderTest.kt @@ -0,0 +1,63 @@ +package app.lawnchair.search.algorithms.engine.provider + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * The calculator row is on by default and becomes the Enter target when no app matches, so it + * must only answer queries that actually calculate something. + */ +@RunWith(Parameterized::class) +class CalculatorSearchProviderTest( + private val query: String, + private val expectedResult: String?, +) { + + @Test + fun calculatorOnlyAnswersRealCalculations() { + val calculation = CalculatorSearchProvider.calculateEquationFromString(query) + + if (expectedResult == null) { + assertWithMessage("calculator row for '$query' showed '${calculation.result}'") + .that(calculation.isValid) + .isFalse() + } else { + assertThat(calculation.isValid).isTrue() + assertThat(calculation.result).isEqualTo(expectedResult) + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0} -> {1}") + fun cases(): List> = listOf( + // Real calculations: a number plus an operator or a function call. + arrayOf("12*7", "84"), + arrayOf("2+2", "4"), + arrayOf("sqrt(16)", "4"), + arrayOf("3^2", "9"), + arrayOf("10/4", "2.5"), + arrayOf("-5+2", "-3"), + arrayOf("7-11", "-4"), + arrayOf("10%4", "2"), + arrayOf("abs(-5)", "5"), + arrayOf("√16", "4"), + arrayOf("2*pi", "6.283185307179586"), + // Bare numbers are not calculations. + arrayOf("2024", null), + arrayOf("-5", null), + arrayOf("3.14", null), + arrayOf("-2.5", null), + arrayOf("1e5", null), + arrayOf("(42)", null), + // Bare constants, or constants without any number, are not calculations. + arrayOf("e", null), + arrayOf("pi", null), + arrayOf("PI", null), + arrayOf("pi*e", null), + ) + } +} diff --git a/android/tests/expressiveUnit/com/android/launcher3/InvariantDeviceProfileLocaleTest.kt b/android/tests/expressiveUnit/com/android/launcher3/InvariantDeviceProfileLocaleTest.kt new file mode 100644 index 00000000000..a525549f46c --- /dev/null +++ b/android/tests/expressiveUnit/com/android/launcher3/InvariantDeviceProfileLocaleTest.kt @@ -0,0 +1,62 @@ +package com.android.launcher3 + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [37], application = Application::class) +class InvariantDeviceProfileLocaleTest { + + @Test + fun languageChange_isAModelPropertyChange() { + // Launcher is recreated on a language change and binds the in-memory model directly, so + // the IDP locale broadcast is the only path that reloads app labels and drawer sections. + val idp = idpWithoutGridInit() + ReflectionHelpers.setField(idp, "mLocale", "en-US") + val before = modelState(idp) + + ReflectionHelpers.setField(idp, "mLocale", "fr-FR") + + assertThat(modelState(idp).asList()).isNotEqualTo(before.asList()) + } + + @Test + fun secondaryLanguageChange_isAModelPropertyChange() { + // Labels without a translation for the first language fall back through the list. + val idp = idpWithoutGridInit() + ReflectionHelpers.setField(idp, "mLocale", "en-US,fr-FR") + val before = modelState(idp) + + ReflectionHelpers.setField(idp, "mLocale", "en-US,de-DE") + + assertThat(modelState(idp).asList()).isNotEqualTo(before.asList()) + } + + @Test + fun unchangedLanguage_isNotAModelPropertyChange() { + val idp = idpWithoutGridInit() + ReflectionHelpers.setField(idp, "mLocale", "en-US") + val before = modelState(idp) + + ReflectionHelpers.setField(idp, "mLocale", "en-US") + + assertThat(modelState(idp).asList()).isEqualTo(before.asList()) + } + + private fun modelState(idp: InvariantDeviceProfile): Array = + ReflectionHelpers.callInstanceMethod(idp, "toModelState") + + /** The grid init needs the full launcher graph; the model state only reads plain fields. */ + private fun idpWithoutGridInit(): InvariantDeviceProfile { + val unsafeClass = Class.forName("sun.misc.Unsafe") + val unsafe = unsafeClass.getDeclaredField("theUnsafe").apply { isAccessible = true } + .get(null) + return unsafeClass.getMethod("allocateInstance", Class::class.java) + .invoke(unsafe, InvariantDeviceProfile::class.java) as InvariantDeviceProfile + } +}