Wire the SearchScreen and the backend of search related data

Change-Id: I103c903c936137c41d7d11be0731438d9ee557d9
recent_search
Takeshi Hagikura 1 year ago
parent 83b640a7f0
commit 6f9d2fe3ba

@ -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,57 @@
/*
* 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.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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val newsResourceFtsDao: NewsResourceFtsDao,
private val topicDao: TopicDao,
private val topicFtsDao: TopicFtsDao,
) : SearchContentsRepository {
override fun populateFtsData() {
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}*")
return combine(
newsResourceDao.getNewsResources(filterNewsIds = newsResourceIds.toSet()),
topicDao.getTopicEntities(topicIds.toSet())
) { newsResources, topics ->
SearchResult(
topics = topics.map { it.asExternalModel() },
newsResources = newsResources.map { it.asExternalModel() }
)
}
}
}

@ -0,0 +1,31 @@
/*
* 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.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlinx.coroutines.flow.Flow
interface SearchContentsRepository {
fun populateFtsData()
fun searchContents(searchQuery: String): Flow<SearchResult>
}
data class SearchResult(
val topics: List<Topic> = emptyList(),
val newsResources: List<NewsResource> = emptyList(),
)

@ -0,0 +1,34 @@
/*
* 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.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Fake implementation of the [SearchContentsRepository]
*/
class FakeSearchContentsRepository @Inject constructor(
) : SearchContentsRepository {
override fun populateFtsData() {}
override fun searchContents(searchQuery: String): Flow<SearchResult> {
TODO("Not yet implemented")
}
}

@ -63,6 +63,8 @@ class TestNewsResourceDao : NewsResourceDao {
result
}
override 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 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",
).build()
// TODO: This is a workaround for executing read query in the main thread for search.
// Figure out how other use cases avoid that
).allowMainThreadQueries().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")
fun getOneOffNewsResources(): List<PopulatedNewsResource>
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/

@ -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.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
/**
* DAO for [NewsResourceFtsEntity] access.
*/
@Dao
interface NewsResourceFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(topics: List<NewsResourceFtsEntity>)
@Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query")
fun searchAllNewsResources(query: String): List<String>
@Query("SELECT count(*) FROM newsResourcesFts")
fun getCount(): Int
}

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

@ -0,0 +1,37 @@
/*
* 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
/**
* DAO for [TopicFtsEntity] access.
*/
@Dao
interface TopicFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(topics: List<TopicFtsEntity>)
@Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query")
fun searchAllTopics(query: String): List<String>
@Query("SELECT count(*) FROM topicsFts")
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,64 @@
/*
* 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.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
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.model.data.UserData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
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)
}
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,
)
},
)
}
data class UserSearchResult(
val topics: List<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),
)

@ -0,0 +1,62 @@
/*
* 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.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchResult
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 fun populateFtsData() {}
override fun searchContents(searchQuery: String): Flow<SearchResult> {
return 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)
}
}

@ -22,6 +22,13 @@ 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
data class Success(
val topics: List<FollowableTopic> = emptyList(),
val newsResources: List<UserNewsResource> = emptyList(),

@ -44,6 +44,8 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -63,6 +65,7 @@ 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
@ -88,6 +91,7 @@ internal fun SearchRoute(
searchViewModel: SearchViewModel = hiltViewModel(),
forYouViewModel: ForYouViewModel = hiltViewModel(),
) {
val uiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle()
SearchScreen(
modifier = modifier,
onBackClick = onBackClick,
@ -96,6 +100,7 @@ internal fun SearchRoute(
onSearchQueryChanged = searchViewModel::onSearchQueryChanged,
onTopicClick = onTopicClick,
onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved,
uiState = uiState
)
}
@ -121,6 +126,7 @@ internal fun SearchScreen(
)
when (uiState) {
SearchResultUiState.Loading -> Unit
SearchResultUiState.EmptyQuery -> Unit
is SearchResultUiState.Success -> {
if (uiState.isEmpty()) {
EmptySearchResultBody(
@ -210,7 +216,7 @@ private fun SearchResultBody(
append(stringResource(id = searchR.string.topics))
}
},
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
TopicsTabContent(
topics = topics,
@ -227,7 +233,7 @@ private fun SearchResultBody(
append(stringResource(id = searchR.string.updates))
}
},
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
val state = rememberLazyGridState()
@ -300,7 +306,10 @@ private fun SearchTextField(
)
},
trailingIcon = {
IconButton(onClick = { searchQuery.value = "" }) {
IconButton(onClick = {
searchQuery.value = ""
onSearchQueryChanged("")
}) {
Icon(
imageVector = NiaIcons.Close,
contentDescription = stringResource(

@ -17,14 +17,45 @@
package com.google.samples.apps.nowinandroid.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
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,
) : ViewModel() {
fun onSearchQueryChanged(searchQuery: String) {
// TODO: Pass it to UseCase
println("New search query is '$searchQuery'")
val searchQuery = MutableStateFlow("")
val searchResultUiState: StateFlow<SearchResultUiState> =
searchQuery.flatMapLatest { query ->
if (query.length < 2) {
flowOf(SearchResultUiState.EmptyQuery)
} else {
getSearchContentsUseCase(query).map {
SearchResultUiState.Success(
topics = it.topics,
newsResources = it.newsResources,
)
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Loading,
)
fun onSearchQueryChanged(query: String) {
searchQuery.value = query
}
}
}

@ -0,0 +1,89 @@
/*
* 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.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,
)
}
@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.searchQuery.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