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:
1 parent
d3c07f8e9c
commit
cd2104334c
16 files changed
+759
-14
No files matched your search
@@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"presubmit": [
|
||||
{
|
||||
"name": "WidgetPickerScreenshotTests"
|
||||
},
|
||||
{
|
||||
"name": "WidgetPickerDeviceTests"
|
||||
},
|
||||
{
|
||||
"name": "WidgetPickerRoboTests"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
+61
@@ -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)
|
||||
}
|
||||
+140
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-6
@@ -93,12 +93,7 @@ fun WidgetAppsList(
|
||||
appIcon = appIcon,
|
||||
title = title,
|
||||
description = description,
|
||||
widgetPreviews =
|
||||
if (selected) {
|
||||
widgetPreviews
|
||||
} else {
|
||||
emptyMap()
|
||||
},
|
||||
widgetPreviews = widgetPreviews,
|
||||
onWidgetAppClick = onWidgetAppClick,
|
||||
)
|
||||
}
|
||||
|
||||
+6
-4
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
File renamed without changes.
+102
@@ -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()
|
||||
}
|
||||
}
|
||||
+228
@@ -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()
|
||||
}
|
||||
}
|
||||
+55
@@ -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() }
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
+71
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user