Add WidgetsInteractor that enables view layer to interact with widgets

- This is responsible for looking up data from repositories and applying
business rules using use cases.

Bug: 408283627
Flag: EXEMPT independent module
Test: Unit tests
Change-Id: I767186a38b3105085637c75d9aec8ca21788f49f
This commit is contained in:
Shamali Patwa committed 2025-05-22 16:37:46 -07:00
1 parent d3c07f8e9c
commit cd2104334c
16 files changed
+759 -14

No files matched your search

+5
View File
@@ -89,11 +89,16 @@ android_library {
srcs: [
"src/com/android/launcher3/widgetpicker/domain/usecase/GroupWidgetAppsByProfileUseCase.kt",
"src/com/android/launcher3/widgetpicker/domain/usecase/FilterWidgetsForHostUseCase.kt",
"src/com/android/launcher3/widgetpicker/domain/interactor/WidgetsInteractor.kt",
"src/com/android/launcher3/widgetpicker/domain/interactor/WidgetAppIconsInteractor.kt",
],
static_libs: [
"widget_picker_shared_data_types",
"widget_picker_data_repositories",
"widget_picker_dagger_annotations",
"dagger2",
"kotlinx-coroutines-android",
"kotlinx-coroutines-core",
],
}
+6
View File
@@ -2,6 +2,12 @@
"presubmit": [
{
"name": "WidgetPickerScreenshotTests"
},
{
"name": "WidgetPickerDeviceTests"
},
{
"name": "WidgetPickerRoboTests"
}
]
}
+2
View File
@@ -108,10 +108,12 @@ dependencies {
testImplementation(libs.google.truth)
testImplementation(libs.androidx.test.runner)
testImplementation(libs.androidx.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.google.truth)
androidTestImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.kotlinx.coroutines.test)
// Compose UI Tests
androidTestApi(libs.compose.ui.test.junit4)
@@ -34,6 +34,13 @@
[CHAR_LIMIT=25] -->
<string name="featured_widgets_tab_label">Featured</string>
<!-- Tab label. A user can tap this tab to access list of all apps hosting appwidgets.
[CHAR_LIMIT=25] -->
<string name="browse_widgets_tab_label">Browse</string>
<!-- accessibilityPaneTitle for the right pane when showing widgets selected on left. -->
<string name="widget_picker_right_pane_accessibility_label"><xliff:g id="selected_header" example="Calendar">%1$s</xliff:g> widgets on right, search and options on left</string>
<!-- Search bar strings -->
<!-- Placeholder text displayed in the widget's search bar [CHAR_LIMIT=50] -->
<string name="widgets_search_bar_hint">Search</string>
@@ -25,8 +25,19 @@ import javax.inject.Scope
@Scope
annotation class WidgetPickerSingleton
/** Qualifier annotation for the background context used in interactors. */
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class WidgetPickerBackground
/** Qualifier annotation for information about the widget host. */
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class WidgetPickerHostInfo
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class WidgetPickerRepository
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.domain.interactor
import com.android.launcher3.widgetpicker.WidgetPickerBackground
import com.android.launcher3.widgetpicker.WidgetPickerRepository
import com.android.launcher3.widgetpicker.WidgetPickerSingleton
import com.android.launcher3.widgetpicker.data.repository.WidgetAppIconsRepository
import com.android.launcher3.widgetpicker.data.repository.WidgetsRepository
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/**
* A domain layer object that enables the UI layer to get the apps icons from data layer.
* - Responsible for merging the necessary information from widgets and app icons repositories to
* provide the app icons.
*/
@WidgetPickerSingleton
class WidgetAppIconsInteractor @Inject constructor(
@WidgetPickerRepository
private val widgetAppIconsRepository: WidgetAppIconsRepository,
@WidgetPickerRepository
private val widgetsRepository: WidgetsRepository,
@WidgetPickerBackground
private val backgroundContext: CoroutineContext,
) {
/** Returns a flow of icons for all widget apps that host widgets. */
@OptIn(ExperimentalCoroutinesApi::class)
fun getAllWidgetAppIcons(): Flow<Map<WidgetAppId, WidgetAppIcon>> =
widgetsRepository.observeWidgets().flatMapLatest { widgetApps ->
val flows: List<Flow<Pair<WidgetAppId, WidgetAppIcon>>> = widgetApps.map { widgetApp ->
widgetAppIconsRepository.getAppIcon(widgetApp.id).map {
widgetApp.id to it
}
}
combine(flows) { it.toMap() }
}.flowOn(backgroundContext)
}
@@ -0,0 +1,140 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.domain.interactor
import com.android.launcher3.widgetpicker.WidgetPickerBackground
import com.android.launcher3.widgetpicker.WidgetPickerRepository
import com.android.launcher3.widgetpicker.WidgetPickerSingleton
import com.android.launcher3.widgetpicker.data.repository.WidgetUsersRepository
import com.android.launcher3.widgetpicker.data.repository.WidgetsRepository
import com.android.launcher3.widgetpicker.domain.usecase.FilterWidgetsForHostUseCase
import com.android.launcher3.widgetpicker.domain.usecase.GroupWidgetAppsByProfileUseCase
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
import com.android.launcher3.widgetpicker.shared.model.WidgetApp
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/**
* A domain layer object that enables the UI layer to interact with the widgets in data layer.
* - Responsible for applying business rules such as applying host constraints, filtering data for
* paused work profile, etc.
*/
@WidgetPickerSingleton
class WidgetsInteractor
@Inject
constructor(
@WidgetPickerRepository
private val widgetsRepository: WidgetsRepository,
@WidgetPickerRepository
private val widgetUsersRepository: WidgetUsersRepository,
private val filterWidgetsForHostUseCase: FilterWidgetsForHostUseCase,
private val getWidgetAppsByProfileUseCase: GroupWidgetAppsByProfileUseCase,
@WidgetPickerBackground
private val backgroundContext: CoroutineContext,
) {
/** Returns the list of widget apps per user profiles. */
fun getWidgetAppsByProfile(): Flow<Map<WidgetUserProfile, List<WidgetApp>>> =
combine(
widgetsRepository.observeWidgets(),
widgetUsersRepository.observeUserProfiles()
) { widgetApps,
widgetUserProfiles ->
val filteredWidgets =
widgetApps
.map { it.copy(widgets = filterWidgetsForHostUseCase(it.widgets)) }
.filter { it.widgets.isNotEmpty() }
.sortedBy { it.title?.toString() ?: "" }
widgetUserProfiles?.let {
getWidgetAppsByProfileUseCase(
filteredWidgets,
widgetUserProfiles,
widgetUsersRepository.getWorkProfileUser(),
)
} ?: emptyMap()
}
.distinctUntilChanged()
.flowOn(backgroundContext)
/** Loads and returns the preview for an appwidget. */
suspend fun getWidgetPreview(widgetId: WidgetId): WidgetPreview =
widgetsRepository.getWidgetPreview(widgetId)
/** Returns widgets that can be featured in the widget picker. */
fun getFeaturedWidgets(): Flow<List<PickableWidget>> =
combine(
widgetsRepository.getFeaturedWidgets(),
widgetUsersRepository.observeUserProfiles(),
) { widgets, widgetUserProfiles ->
val filteredWidgets = filterWidgetsForHostUseCase(widgets)
val hasPausedWorkProfile =
widgetUserProfiles?.work?.paused ?: false
val workProfileUser =
if (hasPausedWorkProfile) {
checkNotNull(widgetUsersRepository.getWorkProfileUser())
} else null
return@combine if (hasPausedWorkProfile) {
filteredWidgets.filter { it.id.userHandle != workProfileUser }
} else {
filteredWidgets
}
}
/***
* Returns the list of widget apps that match the given plain text [query] string entered by the
* user. The widget's label, description and app's title is expected to be considered for
* returning matches.
*
* Filters out
* - widgets that don't match the host criteria
* - when work profile is paused, work profile widgets are also filtered out.
*/
suspend fun searchWidgetApps(query: String): Flow<List<WidgetApp>> =
withContext(backgroundContext) {
val matchedWidgetApps = widgetsRepository.searchWidgets(query).map {
it.copy(
widgets = filterWidgetsForHostUseCase(it.widgets)
)
}.filter { it.widgets.isNotEmpty() }
// business rule: We don't show work profile widgets when profile is paused.
val workProfileUser = widgetUsersRepository.getWorkProfileUser()
if (workProfileUser != null) {
widgetUsersRepository.observeUserProfiles().map { profiles ->
val paused = checkNotNull(profiles?.work).paused
if (paused) {
matchedWidgetApps.filter { it.id.userHandle != workProfileUser }
} else {
matchedWidgetApps
}
}
} else {
flowOf(matchedWidgetApps)
}
}
}
@@ -93,12 +93,7 @@ fun WidgetAppsList(
appIcon = appIcon,
title = title,
description = description,
widgetPreviews =
if (selected) {
widgetPreviews
} else {
emptyMap()
},
widgetPreviews = widgetPreviews,
onWidgetAppClick = onWidgetAppClick,
)
}
@@ -80,11 +80,11 @@ fun ExpandableListHeader(
modifier
.clip(shape = shape)
.background(color = ExpandedListHeaderDefaults.backgroundColor)
.clickable { onClick() }
Column(modifier = finalModifier) {
WidgetAppHeader(
modifier = Modifier,
modifier = Modifier
.clickable { onClick() },
leadingIcon = { leadingAppIcon() },
title = title,
subTitle = subTitle,
@@ -192,7 +192,8 @@ fun SelectableSuggestionsHeader(
imageVector = Icons.Filled.Star,
contentDescription = null,
modifier =
Modifier.clip(shape)
Modifier
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceBright)
.minimumInteractiveComponentSize(),
)
@@ -224,7 +225,8 @@ private fun WidgetAppHeader(
leadingIcon()
CenterText(
modifier =
Modifier.weight(1f)
Modifier
.weight(1f)
.padding(horizontal = ListHeaderDimensions.centerTextHorizontalPadding),
title = title,
subTitle = subTitle,
+18 -4
View File
@@ -29,12 +29,13 @@ android_app {
certificate: "platform",
system_ext_specific: true,
static_libs: [
"widget_picker_ui",
"widget_picker_ui_components",
"widget_picker_domain_layer",
"widget_picker_data_repositories",
],
kotlincflags: ["-Xjvm-default=all"],
use_resource_processor: true,
use_resource_processor: false,
}
android_test {
@@ -88,6 +89,11 @@ filegroup {
"multivalentTests/src/com/android/launcher3/widgetpicker/TestUtils.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/domain/usecase/GroupWidgetAppsByProfileUseCaseTest.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/domain/usecase/FilterWidgetsForHostUseCaseTest.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/domain/interactor/WidgetsInteractorTest.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/domain/interactor/WidgetAppIconsInteractorTest.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/repository/FakeWidgetsRepository.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/repository/FakeWidgetUsersRepository.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/repository/FakeWidgetAppIconsRepository.kt",
],
}
@@ -101,6 +107,7 @@ android_test {
"widget_picker_data_repositories",
"collector-device-lib-platform",
"testables",
"platform-test-annotations",
"platform-test-rules",
"platform-parametric-runner-lib",
"junit",
@@ -124,8 +131,8 @@ android_test {
compile_multilib: "both",
instrumentation_for: "WidgetPickerTestApp",
plugins: ["dagger2-compiler"],
test_suites: ["general-tests"],
test_config: "WidgetPickerTests.xml",
test_suites: ["device-tests"],
test_config: "WidgetPickerDeviceTests.xml",
}
android_robolectric_test {
@@ -138,6 +145,12 @@ android_robolectric_test {
"widget_picker_data_repositories",
"kotlinx_coroutines_test",
"platform-test-rules-deviceless",
"testables",
"platform-parametric-runner-lib",
"junit",
"androidx.test.ext.junit",
"platform-test-annotations",
"androidx.test.rules",
],
libs: [
"android.test.runner.impl",
@@ -147,5 +160,6 @@ android_robolectric_test {
],
plugins: ["dagger2-compiler"],
instrumentation_for: "WidgetPickerTestApp",
strict_mode: false,
strict_mode: true,
test_suites: ["general-tests"],
}
@@ -0,0 +1,102 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.domain.interactor
import android.graphics.Bitmap
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.launcher3.widgetpicker.TestUtils.PERSONAL_TEST_APPS
import com.android.launcher3.widgetpicker.TestUtils.WORK_TEST_APPS
import com.android.launcher3.widgetpicker.repository.FakeWidgetAppIconsRepository
import com.android.launcher3.widgetpicker.repository.FakeWidgetsRepository
import com.android.launcher3.widgetpicker.shared.model.AppIcon
import com.android.launcher3.widgetpicker.shared.model.AppIconBadge
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
class WidgetAppIconsInteractorTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val widgetsRepository = FakeWidgetsRepository()
private val widgetAppIconsRepository = FakeWidgetAppIconsRepository()
private val underTest = WidgetAppIconsInteractor(
widgetAppIconsRepository = widgetAppIconsRepository,
widgetsRepository = widgetsRepository,
backgroundContext = testDispatcher
)
@Test
fun returnsMappedAppIcons() = testScope.runTest {
widgetsRepository.seedWidgets(
listOf(
PERSONAL_TEST_APPS[0],
PERSONAL_TEST_APPS[1],
WORK_TEST_APPS[0]
)
)
val widgetAppId1 = PERSONAL_TEST_APPS[0].id
val widgetAppId2 = PERSONAL_TEST_APPS[1].id
val widgetAppId3 = WORK_TEST_APPS[0].id // no app icon yet
val appIcon1 = WidgetAppIcon(
icon = AppIcon.LowResColorIcon(color = Color.BLUE),
badge = AppIconBadge.NoBadge
)
val appIcon2 = WidgetAppIcon(
icon = AppIcon.HighResBitmapIcon(
Bitmap.createBitmap(
50,
50,
Bitmap.Config.ARGB_8888
)
),
badge = AppIconBadge.DrawableBadge(
drawableResId = android.R.drawable.ic_menu_add,
tintColor = Color.RED
)
)
widgetAppIconsRepository.seedAppIcons(
mapOf(
widgetAppId1 to appIcon1,
widgetAppId2 to appIcon2,
// no widgetAppId3
)
)
val expected = mapOf(
widgetAppId1 to appIcon1,
widgetAppId2 to appIcon2,
widgetAppId3 to WidgetAppIcon(
icon = AppIcon.PlaceHolderAppIcon,
badge = AppIconBadge.NoBadge
)
)
runCurrent()
underTest.getAllWidgetAppIcons().filter { it == expected }.first() // no error
runCurrent()
}
}
@@ -0,0 +1,228 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.domain.interactor
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.launcher3.widgetpicker.TestUtils.PERSONAL_TEST_APPS
import com.android.launcher3.widgetpicker.TestUtils.WORK_TEST_APPS
import com.android.launcher3.widgetpicker.TestUtils.widgetUserProfilePersonal
import com.android.launcher3.widgetpicker.TestUtils.widgetUserProfileWork
import com.android.launcher3.widgetpicker.TestUtils.workUser
import com.android.launcher3.widgetpicker.domain.usecase.FilterWidgetsForHostUseCase
import com.android.launcher3.widgetpicker.domain.usecase.GroupWidgetAppsByProfileUseCase
import com.android.launcher3.widgetpicker.repository.FakeWidgetUsersRepository
import com.android.launcher3.widgetpicker.repository.FakeWidgetsRepository
import com.android.launcher3.widgetpicker.shared.model.WidgetHostInfo
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfiles
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
class WidgetsInteractorTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val widgetUsersRepository = FakeWidgetUsersRepository()
private val widgetsRepository = FakeWidgetsRepository()
private val underTest =
WidgetsInteractor(
widgetsRepository = widgetsRepository,
widgetUsersRepository = widgetUsersRepository,
getWidgetAppsByProfileUseCase = GroupWidgetAppsByProfileUseCase(),
filterWidgetsForHostUseCase = FilterWidgetsForHostUseCase(WidgetHostInfo()),
backgroundContext = testDispatcher
)
@Test
fun noWidgets_returnsEmpty() =
testScope.runTest {
assertThat(underTest.getWidgetAppsByProfile().first()).isEmpty()
assertThat(underTest.getFeaturedWidgets().first()).isEmpty()
runCurrent()
}
@Test
fun userProfilesNotLoaded_returnsEmpty() =
testScope.runTest {
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
runCurrent()
assertThat(underTest.getWidgetAppsByProfile().first()).isEmpty()
assertThat(underTest.getFeaturedWidgets().first()).isEmpty()
runCurrent()
}
@Test
fun getWidgetAppsByProfile_returnsWidgetGroupedByProfile() =
testScope.runTest {
widgetUsersRepository.seedUserProfiles(
profiles =
WidgetUserProfiles(
personal = widgetUserProfilePersonal,
work = widgetUserProfileWork,
),
workProfileUser = workUser,
)
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
runCurrent()
val result = underTest.getWidgetAppsByProfile().first()
runCurrent()
assertThat(result).hasSize(2)
assertThat(result[widgetUserProfilePersonal])
.containsExactlyElementsIn(PERSONAL_TEST_APPS)
assertThat(result[widgetUserProfileWork]).containsExactlyElementsIn(WORK_TEST_APPS)
}
@Test
fun getFeaturedWidgets_returnsAllFeaturedWidgets() =
testScope.runTest {
widgetUsersRepository.seedUserProfiles(
profiles =
WidgetUserProfiles(
personal = widgetUserProfilePersonal,
work = widgetUserProfileWork,
),
workProfileUser = workUser,
)
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
val personalFeaturedWidget = PERSONAL_TEST_APPS[0].widgets[0]
val workFeaturedWidget = WORK_TEST_APPS[0].widgets[0]
widgetsRepository.seedFeaturedWidgets(
setOf(workFeaturedWidget.id, personalFeaturedWidget.id)
)
runCurrent()
val result = underTest.getFeaturedWidgets().first()
runCurrent()
assertThat(result).containsExactly(personalFeaturedWidget, workFeaturedWidget)
}
@Test
fun getFeaturedWidgets_pausedWorkProfile_returnsOnlyPersonalWidgets() =
testScope.runTest {
widgetUsersRepository.seedUserProfiles(
profiles =
WidgetUserProfiles(
personal = widgetUserProfilePersonal,
work = widgetUserProfileWork.copy(paused = true),
),
workProfileUser = workUser,
)
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
val personalFeaturedWidget = PERSONAL_TEST_APPS[0].widgets[0]
val workFeaturedWidget = WORK_TEST_APPS[0].widgets[0]
widgetsRepository.seedFeaturedWidgets(
setOf(workFeaturedWidget.id, personalFeaturedWidget.id)
)
runCurrent()
val result = underTest.getFeaturedWidgets().first()
runCurrent()
assertThat(result).containsExactly(personalFeaturedWidget)
}
@Test
fun getWidgetPreviews_returnsExpectedPreviews() =
testScope.runTest {
widgetUsersRepository.seedUserProfiles(
profiles =
WidgetUserProfiles(
personal = widgetUserProfilePersonal,
work = widgetUserProfileWork,
),
workProfileUser = workUser,
)
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
val personalWidgetId = PERSONAL_TEST_APPS[0].widgets[0].id
val personalWidgetPreview =
WidgetPreview.BitmapWidgetPreview(
Bitmap.createBitmap(50, 50, Bitmap.Config.ARGB_8888)
)
val workWidgetId = WORK_TEST_APPS[0].widgets[0].id
widgetsRepository.seedWidgetPreviews(
mapOf(
personalWidgetId to personalWidgetPreview
) // no work widget preview available.
)
runCurrent()
assertThat(underTest.getWidgetPreview(personalWidgetId))
.isEqualTo(personalWidgetPreview)
assertThat(underTest.getWidgetPreview(workWidgetId))
.isEqualTo(WidgetPreview.PlaceholderWidgetPreview)
}
@Test
fun searchWidgets_returnsExpectedWidgets() =
testScope.runTest {
widgetUsersRepository.seedUserProfiles(
profiles =
WidgetUserProfiles(
personal = widgetUserProfilePersonal,
work = widgetUserProfileWork,
),
workProfileUser = workUser,
)
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
runCurrent()
val input = PERSONAL_TEST_APPS[0].widgets[0].label
val result = underTest.searchWidgetApps(input).first()
runCurrent()
assertThat(result.size).isEqualTo(1)
assertThat(result[0].widgets.size).isEqualTo(1)
assertThat(result[0].widgets[0].label).isEqualTo(input)
}
@Test
fun searchWidgets_pausedWorkProfile_returnsOnlyPersonalWidgets() =
testScope.runTest {
widgetUsersRepository.seedUserProfiles(
profiles =
WidgetUserProfiles(
personal = widgetUserProfilePersonal,
work = widgetUserProfileWork.copy(paused = true),
),
workProfileUser = workUser,
)
widgetsRepository.seedWidgets(PERSONAL_TEST_APPS + WORK_TEST_APPS)
runCurrent()
val input = "Widget"
val result = underTest.searchWidgetApps(input).first()
runCurrent()
assertThat(result.size).isEqualTo(PERSONAL_TEST_APPS.size)
assertThat(result.filter { it.title == WORK_TEST_APPS[0].title }).isEmpty()
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.repository
import com.android.launcher3.widgetpicker.data.repository.WidgetAppIconsRepository
import com.android.launcher3.widgetpicker.shared.model.AppIcon
import com.android.launcher3.widgetpicker.shared.model.AppIconBadge
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
/** A fake implementation of [WidgetAppIconsRepository] for testing */
class FakeWidgetAppIconsRepository : WidgetAppIconsRepository {
private val _appIcons = MutableStateFlow(emptyMap<WidgetAppId, WidgetAppIcon>())
fun seedAppIcons(icons: Map<WidgetAppId, WidgetAppIcon>) {
_appIcons.update { icons }
}
override fun initialize() {}
override fun getAppIcon(widgetAppId: WidgetAppId): Flow<WidgetAppIcon> = flow {
emit(
WidgetAppIcon(
icon = AppIcon.PlaceHolderAppIcon,
badge = AppIconBadge.NoBadge
)
)
val appIcon = _appIcons.value[widgetAppId]
if (appIcon != null) {
emit(appIcon)
}
}
override fun cleanUp() {
_appIcons.update { emptyMap() }
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.repository
import android.os.UserHandle
import com.android.launcher3.widgetpicker.data.repository.WidgetUsersRepository
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfiles
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/** A fake implementation of [WidgetUsersRepository] for testing */
class FakeWidgetUsersRepository : WidgetUsersRepository {
private val _userProfiles = MutableStateFlow<WidgetUserProfiles?>(null)
private var workProfileUser: UserHandle? = null
fun seedUserProfiles(profiles: WidgetUserProfiles?, workProfileUser: UserHandle?) {
this.workProfileUser = workProfileUser
_userProfiles.update { profiles }
}
override fun initialize() {}
override fun observeUserProfiles(): Flow<WidgetUserProfiles?> = _userProfiles
override fun getWorkProfileUser(): UserHandle? = workProfileUser
override fun cleanUp() {
_userProfiles.update { null }
workProfileUser = null
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.widgetpicker.repository
import com.android.launcher3.widgetpicker.data.repository.WidgetsRepository
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
import com.android.launcher3.widgetpicker.shared.model.WidgetApp
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
/** A fake implementation of [WidgetsRepository] for testing */
class FakeWidgetsRepository : WidgetsRepository {
private val _widgetApps = MutableStateFlow<List<WidgetApp>>(emptyList())
private val _widgetPreviews = MutableStateFlow<Map<WidgetId, WidgetPreview>>(emptyMap())
private var _featuredWidgetIds = MutableStateFlow(emptySet<WidgetId>())
fun seedWidgets(widgetApps: List<WidgetApp>) {
_widgetApps.update { widgetApps }
}
fun seedWidgetPreviews(map: Map<WidgetId, WidgetPreview>) {
_widgetPreviews.update { map }
}
fun seedFeaturedWidgets(widgetIds: Set<WidgetId>) {
_featuredWidgetIds.update { widgetIds }
}
override fun initialize() {}
override fun observeWidgets(): Flow<List<WidgetApp>> = _widgetApps
override suspend fun getWidgetPreview(id: WidgetId): WidgetPreview =
_widgetPreviews.value[id] ?: WidgetPreview.PlaceholderWidgetPreview
override fun getFeaturedWidgets(): Flow<List<PickableWidget>> =
combine(_featuredWidgetIds, _widgetApps) { widgetIds, widgetApps ->
widgetApps.flatMap { it.widgets }.filter { widgetIds.contains(it.id) }
}
override suspend fun searchWidgets(input: String): List<WidgetApp> = _widgetApps.value.map {
it.copy(widgets = it.widgets.filter { widget ->
val description = widget.description?.toString() ?: ""
widget.label.contains(input) || description.contains(input)
})
}.filter { it.widgets.isNotEmpty() }
override fun cleanUp() {
_widgetApps.update { emptyList() }
_widgetPreviews.update { emptyMap() }
_featuredWidgetIds.update { emptySet() }
}
}