Search backend (#668)

Adds search UI (same change as #656)

- Empty search screen if the search result is empty
- Topics contents that reuses the TopicsTabContent for InterestsScreen
- Updates contents that reuses the newsFeed for ForYouScreen
TODO: Needs to add RecentSearch

Adds search backend that wires the backend data with the search UI

- Add Fts related tables.
- Add SearchContentsRepository that takes care of populating the Fts tables and search the contents from the Fts tables.
- Wire the SearchContentsRepository with the SearchViewModel
search_fix_comment
Takeshi Hagikura 1 year ago committed by GitHub
parent d36fe8df59
commit 37b0917002
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
@ -27,6 +26,8 @@ import com.google.samples.apps.nowinandroid.feature.interests.navigation.interes
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -37,10 +38,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
*/
@Composable
fun NiaNavHost(
navController: NavHostController,
appState: NiaAppState,
modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = startDestination,
@ -49,7 +51,11 @@ fun NiaNavHost(
// TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {})
searchScreen(onBackClick = navController::popBackStack)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToTopic,
)
interestsGraph(
onTopicClick = { topicId ->
navController.navigateToTopic(topicId)

@ -181,7 +181,7 @@ fun NiaApp(
)
}
NiaNavHost(appState.navController)
NiaNavHost(appState)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that

@ -18,9 +18,11 @@ package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.di.DataModule
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
@ -50,6 +52,11 @@ interface TestDataModule {
userDataRepository: FakeUserDataRepository,
): UserDataRepository
@Binds
fun bindsSearchContentsRepository(
searchContentsRepository: FakeSearchContentsRepository,
): SearchContentsRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor,

@ -16,10 +16,12 @@
package com.google.samples.apps.nowinandroid.core.data.di
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor
@ -48,6 +50,11 @@ interface DataModule {
userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository
@Binds
fun bindsSearchContentsRepository(
searchContentsRepository: DefaultSearchContentsRepository,
): SearchContentsRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor,

@ -0,0 +1,26 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
/** */
data class SearchResult(
val topics: List<Topic> = emptyList(),
val newsResources: List<NewsResource> = emptyList(),
)

@ -0,0 +1,77 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val newsResourceFtsDao: NewsResourceFtsDao,
private val topicDao: TopicDao,
private val topicFtsDao: TopicFtsDao,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : SearchContentsRepository {
override suspend fun populateFtsData() {
withContext(ioDispatcher) {
newsResourceFtsDao.insertAll(
newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() },
)
topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })
}
}
override fun searchContents(searchQuery: String): Flow<SearchResult> {
// Surround the query by asterisks to match the query when it's in the middle of
// a word
val newsResourceIds = newsResourceFtsDao.searchAllNewsResources("*$searchQuery*")
val topicIds = topicFtsDao.searchAllTopics("*$searchQuery*")
val newsResourcesFlow = newsResourceIds
.mapLatest { it.toSet() }
.distinctUntilChanged()
.flatMapLatest {
newsResourceDao.getNewsResources(filterNewsIds = it)
}
val topicsFlow = topicIds
.mapLatest { it.toSet() }
.distinctUntilChanged()
.flatMapLatest(topicDao::getTopicEntities)
return combine(newsResourcesFlow, topicsFlow) { newsResources, topics ->
SearchResult(
topics = topics.map { it.asExternalModel() },
newsResources = newsResources.map { it.asExternalModel() },
)
}
}
}

@ -0,0 +1,36 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import kotlinx.coroutines.flow.Flow
/**
* Data layer interface for the search feature.
*/
interface SearchContentsRepository {
/**
* Populate the fts tables for the search contents.
*/
suspend fun populateFtsData()
/**
* Query the contents matched with the [searchQuery] and returns it as a [Flow] of [SearchResult]
*/
fun searchContents(searchQuery: String): Flow<SearchResult>
}

@ -0,0 +1,32 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
* Fake implementation of the [SearchContentsRepository]
*/
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
}

@ -63,6 +63,8 @@ class TestNewsResourceDao : NewsResourceDao {
result
}
override suspend fun getOneOffNewsResources(): List<PopulatedNewsResource> = emptyList()
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>,
): List<Long> {

@ -43,6 +43,8 @@ class TestTopicDao : TopicDao {
getTopicEntities()
.map { topics -> topics.filter { it.id in ids } }
override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList()
override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {
// Keep old values over new values
entitiesStateFlow.update { oldValues ->

@ -0,0 +1,282 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "b6b299e53da623b16360975581ebfcfe",
"entities": [
{
"tableName": "news_resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "headerImageUrl",
"columnName": "header_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topic_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"news_resource_id",
"topic_id"
]
},
"indices": [
{
"name": "index_news_resources_topics_news_resource_id",
"unique": false,
"columnNames": [
"news_resource_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)"
},
{
"name": "index_news_resources_topics_topic_id",
"unique": false,
"columnNames": [
"topic_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)"
}
],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "topics",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"topic_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "newsResourcesFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "newsResourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "imageUrl",
"columnName": "imageUrl",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "topicsFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "topicId",
"columnName": "topicId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6b299e53da623b16360975581ebfcfe')"
]
}
}

@ -17,7 +17,9 @@
package com.google.samples.apps.nowinandroid.core.database
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -35,4 +37,14 @@ object DaosModule {
fun providesNewsResourceDao(
database: NiaDatabase,
): NewsResourceDao = database.newsResourceDao()
@Provides
fun providesTopicFtsDao(
database: NiaDatabase,
): TopicFtsDao = database.topicFtsDao()
@Provides
fun providesNewsResourceFtsDao(
database: NiaDatabase,
): NewsResourceFtsDao = database.newsResourceFtsDao()
}

@ -36,5 +36,7 @@ object DatabaseModule {
context,
NiaDatabase::class.java,
"nia-database",
// TODO: This is a workaround for executing read query in the main thread for search.
// Figure out how other use cases avoid that
).build()
}

@ -21,10 +21,14 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter
@ -32,9 +36,11 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
entities = [
NewsResourceEntity::class,
NewsResourceTopicCrossRef::class,
NewsResourceFtsEntity::class,
TopicEntity::class,
TopicFtsEntity::class,
],
version = 12,
version = 13,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
@ -47,6 +53,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
AutoMigration(from = 12, to = 13),
],
exportSchema = true,
)
@ -57,4 +64,6 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao
abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao
abstract fun newsResourceFtsDao(): NewsResourceFtsDao
}

@ -66,6 +66,10 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>>
@Transaction
@Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC")
suspend fun getOneOffNewsResources(): List<PopulatedNewsResource>
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/

@ -0,0 +1,39 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [NewsResourceFtsEntity] access.
*/
@Dao
interface NewsResourceFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(newsResources: List<NewsResourceFtsEntity>)
@Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query")
fun searchAllNewsResources(query: String): Flow<List<String>>
@Query("SELECT count(*) FROM newsResourcesFts")
suspend fun getCount(): Int
}

@ -41,6 +41,9 @@ interface TopicDao {
@Query(value = "SELECT * FROM topics")
fun getTopicEntities(): Flow<List<TopicEntity>>
@Query(value = "SELECT * FROM topics")
suspend fun getOneOffTopicEntities(): List<TopicEntity>
@Query(
value = """
SELECT * FROM topics

@ -0,0 +1,39 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [TopicFtsEntity] access.
*/
@Dao
interface TopicFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(topics: List<TopicFtsEntity>)
@Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query")
fun searchAllTopics(query: String): Flow<List<String>>
@Query("SELECT count(*) FROM topicsFts")
suspend fun getCount(): Int
}

@ -0,0 +1,44 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
/**
* Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4.
*/
@Entity(tableName = "newsResourcesFts")
@Fts4
data class NewsResourceFtsEntity(
@ColumnInfo(name = "newsResourceId")
val newsResourceId: String,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "content")
val content: String,
)
fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity(
newsResourceId = id,
title = title,
content = content,
)

@ -49,3 +49,9 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource(
type = entity.type,
topics = topics.map(TopicEntity::asExternalModel),
)
fun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity(
newsResourceId = entity.id,
title = entity.title,
content = entity.content,
)

@ -0,0 +1,48 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
/**
* Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4.
*/
@Entity(tableName = "topicsFts")
@Fts4
data class TopicFtsEntity(
@ColumnInfo(name = "topicId")
val topicId: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "shortDescription")
val shortDescription: String,
@ColumnInfo(name = "longDescription")
val longDescription: String,
)
fun TopicEntity.asFtsEntity() = TopicFtsEntity(
topicId = id,
name = name,
shortDescription = shortDescription,
longDescription = longDescription,
)

@ -0,0 +1,61 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.UserSearchResult
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/**
* A use case which returns the searched contents matched with the search query.
*/
class GetSearchContentsUseCase @Inject constructor(
private val searchContentsRepository: SearchContentsRepository,
private val userDataRepository: UserDataRepository,
) {
operator fun invoke(
searchQuery: String,
): Flow<UserSearchResult> =
searchContentsRepository.searchContents(searchQuery)
.mapToUserSearchResult(userDataRepository.userData)
}
private fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> =
combine(userDataStream) { searchResult, userData ->
UserSearchResult(
topics = searchResult.topics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics,
)
},
newsResources = searchResult.newsResources.map { news ->
UserNewsResource(
newsResource = news,
userData = userData,
)
},
)
}

@ -0,0 +1,28 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
/**
* An entity of [SearchResult] with additional user information such as whether the user is
* following a topic.
*/
data class UserSearchResult(
val topics: List<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),
)

@ -0,0 +1,60 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.core.testing.repository
import com.google.samples.apps.nowinandroid.core.data.model.SearchResult
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class TestSearchContentsRepository : SearchContentsRepository {
private val cachedTopics: MutableList<Topic> = mutableListOf()
private val cachedNewsResources: MutableList<NewsResource> = mutableListOf()
override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf(
SearchResult(
topics = cachedTopics.filter {
it.name.contains(searchQuery) ||
it.shortDescription.contains(searchQuery) ||
it.longDescription.contains(searchQuery)
},
newsResources = cachedNewsResources.filter {
it.content.contains(searchQuery) ||
it.title.contains(searchQuery)
},
),
)
/**
* Test only method to add the topics to the stored list in memory
*/
fun addTopics(topics: List<Topic>) {
cachedTopics.addAll(topics)
}
/**
* Test only method to add the news resources to the stored list in memory
*/
fun addNewsResources(newsResources: List<NewsResource>) {
cachedNewsResources.addAll(newsResources)
}
}

@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@ -36,100 +37,100 @@ import kotlinx.datetime.toInstant
* provides list of [UserNewsResource] for Composable previews.
*/
class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider<List<UserNewsResource>> {
override val values: Sequence<List<UserNewsResource>>
get() {
val userData: UserData = UserData(
bookmarkedNewsResources = setOf("1", "3"),
followedTopics = emptySet(),
themeBrand = ThemeBrand.ANDROID,
darkThemeConfig = DarkThemeConfig.DARK,
shouldHideOnboarding = true,
useDynamicColor = false,
)
override val values: Sequence<List<UserNewsResource>> = sequenceOf(newsResources)
}
object PreviewParameterData {
private val userData: UserData = UserData(
bookmarkedNewsResources = setOf("1", "3"),
followedTopics = emptySet(),
themeBrand = ThemeBrand.ANDROID,
darkThemeConfig = DarkThemeConfig.DARK,
shouldHideOnboarding = true,
useDynamicColor = false,
)
val topics = listOf(
Topic(
id = "2",
name = "Headlines",
shortDescription = "News we want everyone to see",
longDescription = "Stay up to date with the latest events and announcements from Android!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f",
url = "",
),
Topic(
id = "3",
name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = "",
),
Topic(
id = "4",
name = "Testing",
shortDescription = "CI, Espresso, TestLab, etc",
longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428",
url = "",
),
)
val topics = listOf(
Topic(
id = "2",
name = "Headlines",
shortDescription = "News we want everyone to see",
longDescription = "Stay up to date with the latest events and announcements from Android!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f",
url = "",
),
Topic(
id = "3",
name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets",
longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594",
url = "",
),
Topic(
id = "4",
name = "Testing",
shortDescription = "CI, Espresso, TestLab, etc",
longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428",
url = "",
),
)
return sequenceOf(
listOf(
UserNewsResource(
newsResource = NewsResource(
id = "1",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
dayOfMonth = 4,
hour = 23,
minute = 0,
second = 0,
nanosecond = 0,
).toInstant(TimeZone.UTC),
type = NewsResourceType.Codelab,
topics = listOf(topics[2]),
),
userData = userData,
),
UserNewsResource(
newsResource = NewsResource(
id = "2",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = topics.take(2),
),
userData = userData,
),
UserNewsResource(
newsResource = NewsResource(
id = "3",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(topics[2]),
),
userData = userData,
),
),
)
}
val newsResources = listOf(
UserNewsResource(
newsResource = NewsResource(
id = "1",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg",
publishDate = LocalDateTime(
year = 2022,
monthNumber = 5,
dayOfMonth = 4,
hour = 23,
minute = 0,
second = 0,
nanosecond = 0,
).toInstant(TimeZone.UTC),
type = NewsResourceType.Codelab,
topics = listOf(topics[2]),
),
userData = userData,
),
UserNewsResource(
newsResource = NewsResource(
id = "2",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
"Summit, our YouTube channel reached 1 million subscribers! Heres a small video to " +
"thank you all.",
url = "https://youtu.be/-fJ6poHQrjM",
headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-09T00:00:00.000Z"),
type = Video,
topics = topics.take(2),
),
userData = userData,
),
UserNewsResource(
newsResource = NewsResource(
id = "3",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +
"create a new pager, and customisation options for consuming " +
"PagingData.",
url = "https://youtu.be/ZARz0pjm5YM",
headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg",
publishDate = Instant.parse("2021-11-01T00:00:00.000Z"),
type = Video,
topics = listOf(topics[2]),
),
userData = userData,
),
)
}

@ -35,6 +35,7 @@ fun TopicsTabContent(
onTopicClick: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
withBottomSpacer: Boolean = true,
) {
LazyColumn(
modifier = modifier
@ -56,8 +57,10 @@ fun TopicsTabContent(
}
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
if (withBottomSpacer) {
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
}

@ -26,3 +26,9 @@ android {
namespace = "com.google.samples.apps.nowinandroid.feature.search"
}
dependencies {
implementation(project(":feature:foryou"))
implementation(project(":feature:interests"))
implementation(libs.kotlinx.datetime)
}

@ -17,13 +17,24 @@
package com.google.samples.apps.nowinandroid.feature.search
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onParent
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR
/**
* UI test for checking the correct behaviour of the Search screen.
@ -33,12 +44,34 @@ class SearchScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var clearSearchText: String
private lateinit var clearSearchContentDesc: String
private lateinit var followButtonContentDesc: String
private lateinit var unfollowButtonContentDesc: String
private lateinit var topicsString: String
private lateinit var updatesString: String
private lateinit var tryAnotherSearchString: String
private val userData: UserData = UserData(
bookmarkedNewsResources = setOf("1", "3"),
followedTopics = emptySet(),
themeBrand = ANDROID,
darkThemeConfig = DARK,
shouldHideOnboarding = true,
useDynamicColor = false,
)
@Before
fun setup() {
composeTestRule.activity.apply {
clearSearchText = getString(R.string.clear_search_text)
clearSearchContentDesc = getString(R.string.clear_search_text_content_desc)
followButtonContentDesc =
getString(interestsR.string.card_follow_button_content_desc)
unfollowButtonContentDesc =
getString(interestsR.string.card_unfollow_button_content_desc)
topicsString = getString(R.string.topics)
updatesString = getString(R.string.updates)
tryAnotherSearchString = getString(R.string.try_another_search) +
" " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics)
}
}
@ -49,10 +82,75 @@ class SearchScreenTest {
}
composeTestRule
.onNodeWithContentDescription(clearSearchText)
.onNodeWithContentDescription(clearSearchContentDesc)
// The parent of the IconButton whose contentDescription matches the clearSearchText
// should be the TextField for search
.onParent()
.assertIsFocused()
}
@Test
fun emptySearchResult_emptyScreenIsDisplayed() {
composeTestRule.setContent {
SearchScreen(
uiState = SearchResultUiState.Success(),
)
}
composeTestRule
.onNodeWithText(tryAnotherSearchString)
.assertIsDisplayed()
}
@Test
fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() {
composeTestRule.setContent {
SearchScreen(
uiState = SearchResultUiState.Success(topics = followableTopicTestData),
)
}
composeTestRule
.onNodeWithText(topicsString)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(followableTopicTestData[0].topic.name)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(followableTopicTestData[1].topic.name)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(followableTopicTestData[2].topic.name)
.assertIsDisplayed()
composeTestRule
.onAllNodesWithContentDescription(followButtonContentDesc)
.assertCountEquals(2)
composeTestRule
.onAllNodesWithContentDescription(unfollowButtonContentDesc)
.assertCountEquals(1)
}
@Test
fun searchResultWithNewsResources_firstNewsResourcesIsVisible() {
composeTestRule.setContent {
SearchScreen(
uiState = SearchResultUiState.Success(
newsResources = newsResourcesTestData.map {
UserNewsResource(
newsResource = it,
userData = userData,
)
},
),
)
}
composeTestRule
.onNodeWithText(updatesString)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(newsResourcesTestData[0].title)
.assertIsDisplayed()
}
}

@ -0,0 +1,40 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.feature.search
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
sealed interface SearchResultUiState {
object Loading : SearchResultUiState
/**
* The state query is empty or too short. To distinguish the state between the
* (initial state or when the search query is cleared) vs the state where no search
* result is returned, explicitly define the empty query state.
*/
object EmptyQuery : SearchResultUiState
object LoadFailed : SearchResultUiState
data class Success(
val topics: List<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),
) : SearchResultUiState {
fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty()
}
}

@ -16,52 +16,90 @@
package com.google.samples.apps.nowinandroid.feature.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.R.string
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent
import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@Composable
internal fun SearchRoute(
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
viewModel: SearchViewModel = hiltViewModel(),
onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit,
interestsViewModel: InterestsViewModel = hiltViewModel(),
searchViewModel: SearchViewModel = hiltViewModel(),
forYouViewModel: ForYouViewModel = hiltViewModel(),
) {
val uiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle()
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
SearchScreen(
modifier = modifier,
onBackClick = onBackClick,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
onFollowButtonClick = interestsViewModel::followTopic,
onInterestsClick = onInterestsClick,
onSearchQueryChanged = searchViewModel::onSearchQueryChanged,
onTopicClick = onTopicClick,
onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved,
searchQuery = searchQuery,
uiState = uiState,
)
}
@ -69,27 +107,162 @@ internal fun SearchRoute(
internal fun SearchScreen(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> },
onInterestsClick: () -> Unit = {},
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> },
onSearchQueryChanged: (String) -> Unit = {},
onTopicClick: (String) -> Unit = {},
searchQuery: String = "",
uiState: SearchResultUiState = SearchResultUiState.Loading,
) {
TrackScreenViewEvent(screenName = "Search")
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(modifier = modifier) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
SearchToolbar(
onBackClick = onBackClick,
onSearchQueryChanged = onSearchQueryChanged,
searchQuery = searchQuery,
)
when (uiState) {
SearchResultUiState.Loading,
SearchResultUiState.LoadFailed,
SearchResultUiState.EmptyQuery,
-> Unit
is SearchResultUiState.Success -> {
if (uiState.isEmpty()) {
EmptySearchResultBody(
onInterestsClick = onInterestsClick,
searchQuery = searchQuery,
)
} else {
SearchResultBody(
topics = uiState.topics,
onFollowButtonClick = onFollowButtonClick,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onTopicClick = onTopicClick,
newsResources = uiState.newsResources,
)
}
}
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
@Composable
fun EmptySearchResultBody(
onInterestsClick: () -> Unit,
searchQuery: String,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val message = stringResource(id = searchR.string.search_result_not_found, searchQuery)
val start = message.indexOf(searchQuery)
Text(
text = AnnotatedString(
text = message,
spanStyles = listOf(
AnnotatedString.Range(
SpanStyle(fontWeight = FontWeight.Bold),
start = start,
end = start + searchQuery.length,
),
),
),
modifier = Modifier.padding(horizontal = 36.dp, vertical = 24.dp),
)
val interests = stringResource(id = searchR.string.interests)
val tryAnotherSearchString = buildAnnotatedString {
append(stringResource(id = searchR.string.try_another_search))
append(" ")
withStyle(
style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
),
) {
pushStringAnnotation(tag = interests, annotation = interests)
append(interests)
}
append(" ")
append(stringResource(id = searchR.string.to_browse_topics))
}
ClickableText(
text = tryAnotherSearchString,
modifier = Modifier
.padding(start = 36.dp, end = 36.dp, bottom = 24.dp)
.clickable {},
) { offset ->
tryAnotherSearchString.getStringAnnotations(start = offset, end = offset)
.firstOrNull()
?.let {
onInterestsClick()
}
}
}
}
@Composable
private fun SearchResultBody(
topics: List<FollowableTopic>,
newsResources: List<UserNewsResource>,
onFollowButtonClick: (String, Boolean) -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
) {
if (topics.isNotEmpty()) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.topics))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
TopicsTabContent(
topics = topics,
onTopicClick = onTopicClick,
onFollowButtonClick = onFollowButtonClick,
withBottomSpacer = false,
)
}
if (newsResources.isNotEmpty()) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.updates))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "search:newsResource")
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxSize()
.testTag("search:newsResources"),
state = state,
) {
newsFeed(
feedState = NewsFeedUiState.Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onTopicClick = onTopicClick,
)
}
}
}
@Composable
private fun SearchToolbar(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onSearchQueryChanged: (String) -> Unit = {},
onBackClick: () -> Unit,
onSearchQueryChanged: (String) -> Unit,
searchQuery: String = "",
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -103,14 +276,19 @@ private fun SearchToolbar(
),
)
}
SearchTextField(onSearchQueryChanged = onSearchQueryChanged)
SearchTextField(
onSearchQueryChanged = onSearchQueryChanged,
searchQuery = searchQuery,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) {
val textState = remember { mutableStateOf("") }
private fun SearchTextField(
onSearchQueryChanged: (String) -> Unit,
searchQuery: String,
) {
val focusRequester = remember { FocusRequester() }
TextField(
colors = TextFieldDefaults.textFieldColors(
@ -128,26 +306,27 @@ private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) {
)
},
trailingIcon = {
IconButton(onClick = { textState.value = "" }) {
IconButton(onClick = {
onSearchQueryChanged("")
}) {
Icon(
imageVector = NiaIcons.Close,
contentDescription = stringResource(
id = searchR.string.clear_search_text,
id = searchR.string.clear_search_text_content_desc,
),
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
onValueChange = {
textState.value = it
onSearchQueryChanged(it)
},
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
.padding(16.dp)
.focusRequester(focusRequester),
shape = RoundedCornerShape(32.dp),
value = textState.value,
value = searchQuery,
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@ -158,14 +337,31 @@ private fun SearchTextField(onSearchQueryChanged: (String) -> Unit) {
@Composable
private fun SearchToolbarPreview() {
NiaTheme {
SearchToolbar()
SearchToolbar(
onBackClick = {},
onSearchQueryChanged = {},
)
}
}
@Preview
@Composable
private fun EmptySearchResultColumnPreview() {
NiaTheme {
EmptySearchResultBody(
onInterestsClick = {},
searchQuery = "C++",
)
}
}
@DevicePreviews
@Composable
private fun SearchScreenPreview() {
private fun SearchScreenPreview(
@PreviewParameter(SearchUiStatePreviewParameterProvider::class)
searchResultUiState: SearchResultUiState,
) {
NiaTheme {
SearchScreen()
SearchScreen(uiState = searchResultUiState)
}
}

@ -0,0 +1,38 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.feature.search
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources
import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics
/* ktlint-disable max-line-length */
/**
* This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider)
* provides list of [SearchResultUiState] for Composable previews.
*/
class SearchUiStatePreviewParameterProvider : PreviewParameterProvider<SearchResultUiState> {
override val values: Sequence<SearchResultUiState> = sequenceOf(
SearchResultUiState.Success(
topics = topics.mapIndexed { i, topic ->
FollowableTopic(topic = topic, isFollowed = i % 2 == 0)
},
newsResources = newsResources,
),
)
}

@ -16,15 +16,65 @@
package com.google.samples.apps.nowinandroid.feature.search
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor() : ViewModel() {
class SearchViewModel @Inject constructor(
// TODO: Add GetSearchContentsCountUseCase to check if the fts tables are populated
getSearchContentsUseCase: GetSearchContentsUseCase,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
fun onSearchQueryChanged(searchQuery: String) {
// TODO: Pass it to UseCase
println("New search query is '$searchQuery'")
val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "")
val searchResultUiState: StateFlow<SearchResultUiState> =
searchQuery.flatMapLatest { query ->
if (query.length < SEARCH_QUERY_MIN_LENGTH) {
flowOf(SearchResultUiState.EmptyQuery)
} else {
getSearchContentsUseCase(query).asResult().map {
when (it) {
is Result.Success -> {
SearchResultUiState.Success(
topics = it.data.topics,
newsResources = it.data.newsResources,
)
}
is Result.Loading -> {
SearchResultUiState.Loading
}
is Result.Error -> {
SearchResultUiState.LoadFailed
}
}
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Loading,
)
fun onSearchQueryChanged(query: String) {
savedStateHandle[SEARCH_QUERY] = query
}
}
/** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */
const val SEARCH_QUERY_MIN_LENGTH = 2
const val SEARCH_QUERY = "searchQuery"

@ -28,10 +28,18 @@ fun NavController.navigateToSearch(navOptions: NavOptions? = null) {
this.navigate(searchRoute, navOptions)
}
fun NavGraphBuilder.searchScreen(onBackClick: () -> Unit) {
fun NavGraphBuilder.searchScreen(
onBackClick: () -> Unit,
onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
// TODO: Handle back stack for each top-level destination. At the moment each top-level
// destination may have own search screen's back stack.
composable(route = searchRoute) {
SearchRoute(onBackClick = onBackClick)
SearchRoute(
onBackClick = onBackClick,
onInterestsClick = onInterestsClick,
onTopicClick = onTopicClick,
)
}
}

@ -16,5 +16,11 @@
-->
<resources>
<string name="search">Search</string>
<string name="clear_search_text">Clear search text</string>
<string name="clear_search_text_content_desc">Clear search text</string>
<string name="search_result_not_found">Sorry, there is no content found for your search \"%1$s\"</string>
<string name="try_another_search">Try another search or explorer </string>
<string name="interests">Interests</string>
<string name="to_browse_topics"> to browse topics</string>
<string name="topics">Topics</string>
<string name="updates">Updates</string>
</resources>

@ -0,0 +1,91 @@
/*
* Copyright 2023 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
*
* https://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.google.samples.apps.nowinandroid.feature.search
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData
import com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData
import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery
import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class SearchViewModelTest {
@get:Rule
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val searchContentsRepository = TestSearchContentsRepository()
private val getSearchContentsUseCase = GetSearchContentsUseCase(
searchContentsRepository = searchContentsRepository,
userDataRepository = userDataRepository,
)
private lateinit var viewModel: SearchViewModel
@Before
fun setup() {
viewModel = SearchViewModel(
getSearchContentsUseCase = getSearchContentsUseCase,
savedStateHandle = SavedStateHandle(),
)
}
@Test
fun stateIsInitiallyLoading() = runTest {
assertEquals(Loading, viewModel.searchResultUiState.value)
}
@Test
fun stateIsEmptyQuery_withEmptySearchQuery() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
viewModel.onSearchQueryChanged("")
assertEquals(EmptyQuery, viewModel.searchResultUiState.value)
collectJob.cancel()
}
@Test
fun emptyResultIsReturned_withNotMatchingQuery() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
viewModel.onSearchQueryChanged("XXX")
searchContentsRepository.addNewsResources(newsResourcesTestData)
searchContentsRepository.addTopics(topicsTestData)
val result = viewModel.searchResultUiState.value
// TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery
// assertIs<Success>(result)
collectJob.cancel()
}
}

@ -27,6 +27,7 @@ import androidx.work.WorkerParameters
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
@ -52,6 +53,7 @@ class SyncWorker @AssistedInject constructor(
private val niaPreferences: NiaPreferencesDataSource,
private val topicRepository: TopicsRepository,
private val newsRepository: NewsRepository,
private val searchContentsRepository: SearchContentsRepository,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val analyticsHelper: AnalyticsHelper,
) : CoroutineWorker(appContext, workerParams), Synchronizer {
@ -72,6 +74,7 @@ class SyncWorker @AssistedInject constructor(
analyticsHelper.logSyncFinished(syncedSuccessfully)
if (syncedSuccessfully) {
searchContentsRepository.populateFtsData()
Result.success()
} else {
Result.retry()

Loading…
Cancel
Save