Add AuthorsRepository to data the layer with sync

Change-Id: I5b9ba0508058332dfa153d24662a95553aa7299e
pull/2/head
Adetunji Dahunsi 2 years ago committed by Don Turner
parent 04157c37da
commit 6f1206ef92

@ -0,0 +1,401 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "fdb65d28086c5a10d129b905ce940aa4",
"entities": [
{
"tableName": "authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "twitter",
"columnName": "twitter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "mediumPage",
"columnName": "medium_page",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_authors_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_authors_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "episodes_authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episode_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`episode_id`, `author_id`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "episodeId",
"columnName": "episode_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorId",
"columnName": "author_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"episode_id",
"author_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "episodes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"episode_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "authors",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"author_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "episodes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `publish_date` INTEGER NOT NULL, `alternate_video` TEXT, `alternate_audio` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alternateVideo",
"columnName": "alternate_video",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "alternateAudio",
"columnName": "alternate_audio",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `author_id` INTEGER NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorId",
"columnName": "author_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"news_resource_id",
"author_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "authors",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"author_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "news_resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `episode_id` INTEGER 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`), FOREIGN KEY(`episode_id`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "episodeId",
"columnName": "episode_id",
"affinity": "INTEGER",
"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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "episodes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"episode_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "news_resources_topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` INTEGER NOT NULL, `topic_id` INTEGER 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": "INTEGER",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topic_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"news_resource_id",
"topic_id"
],
"autoGenerate": false
},
"indices": [],
"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"
]
}
]
},
{
"tableName": "topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER 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": "INTEGER",
"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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_topics_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_topics_name` ON `${TABLE_NAME}` (`name`)"
}
],
"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, 'fdb65d28086c5a10d129b905ce940aa4')"
]
}
}

@ -44,11 +44,12 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
NewsResourceTopicCrossRef::class, NewsResourceTopicCrossRef::class,
TopicEntity::class, TopicEntity::class,
], ],
version = 4, version = 5,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
AutoMigration(from = 3, to = 4), AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
], ],
exportSchema = true, exportSchema = true,
) )

@ -38,10 +38,16 @@ data class AuthorEntity(
val name: String, val name: String,
@ColumnInfo(name = "image_url") @ColumnInfo(name = "image_url")
val imageUrl: String, val imageUrl: String,
@ColumnInfo(defaultValue = "")
val twitter: String,
@ColumnInfo(name = "medium_page", defaultValue = "")
val mediumPage: String,
) )
fun AuthorEntity.asExternalModel() = Author( fun AuthorEntity.asExternalModel() = Author(
id = id, id = id,
name = name, name = name,
imageUrl = imageUrl, imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
) )

@ -17,8 +17,10 @@
package com.google.samples.apps.nowinandroid.core.domain.test package com.google.samples.apps.nowinandroid.core.domain.test
import com.google.samples.apps.nowinandroid.core.domain.di.DomainModule import com.google.samples.apps.nowinandroid.core.domain.di.DomainModule
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeAuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeNewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeTopicsRepository
import dagger.Binds import dagger.Binds
@ -37,6 +39,11 @@ interface TestDomainModule {
fakeTopicsRepository: FakeTopicsRepository fakeTopicsRepository: FakeTopicsRepository
): TopicsRepository ): TopicsRepository
@Binds
fun bindsAuthorRepository(
fakeAuthorsRepository: FakeAuthorsRepository
): AuthorsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository fakeNewsRepository: FakeNewsRepository

@ -16,6 +16,8 @@
package com.google.samples.apps.nowinandroid.core.domain.di package com.google.samples.apps.nowinandroid.core.domain.di
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.LocalAuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.LocalNewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.LocalNewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.LocalTopicsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.LocalTopicsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
@ -34,6 +36,11 @@ interface DomainModule {
topicsRepository: LocalTopicsRepository topicsRepository: LocalTopicsRepository
): TopicsRepository ): TopicsRepository
@Binds
fun bindsAuthorsRepository(
authorsRepository: LocalAuthorsRepository
): AuthorsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
newsRepository: LocalNewsRepository newsRepository: LocalNewsRepository

@ -22,5 +22,7 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
fun NetworkAuthor.asEntity() = AuthorEntity( fun NetworkAuthor.asEntity() = AuthorEntity(
id = id, id = id,
name = name, name = name,
imageUrl = imageUrl imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
) )

@ -69,6 +69,8 @@ fun NetworkNewsResource.authorEntityShells() =
id = authorId, id = authorId,
name = "", name = "",
imageUrl = "", imageUrl = "",
twitter = "",
mediumPage = "",
) )
} }

@ -0,0 +1,33 @@
/*
* Copyright 2022 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.repository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import kotlinx.coroutines.flow.Flow
interface AuthorsRepository {
/**
* Gets the available Authors as a stream
*/
fun getAuthorsStream(): Flow<List<Author>>
/**
* Synchronizes the local database in backing the repository with the network.
* Returns if the sync was successful or not.
*/
suspend fun sync(): Boolean
}

@ -0,0 +1,50 @@
/*
* Copyright 2022 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.repository
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.domain.model.asEntity
import com.google.samples.apps.nowinandroid.core.domain.suspendRunCatching
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Room database backed implementation of the [AuthorsRepository].
*/
class LocalAuthorsRepository @Inject constructor(
private val authorDao: AuthorDao,
private val network: NiANetwork,
) : AuthorsRepository {
override fun getAuthorsStream(): Flow<List<Author>> =
authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) }
// TODO: Pass change list for incremental sync. See b/227206738
override suspend fun sync(): Boolean = suspendRunCatching {
val networkAuthors = network.getAuthors()
authorDao.upsertAuthors(
entities = networkAuthors.map(NetworkAuthor::asEntity)
)
}.isSuccess
}

@ -0,0 +1,60 @@
/*
* Copyright 2022 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.repository.fake
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
/**
* Fake implementation of the [AuthorsRepository] that retrieves the Authors from a JSON String, and
* uses a local DataStore instance to save and retrieve followed Author ids.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeAuthorsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json,
) : AuthorsRepository {
override fun getAuthorsStream(): Flow<List<Author>> = flow {
emit(
networkJson.decodeFromString<List<NetworkAuthor>>(FakeDataSource.authors).map {
Author(
id = it.id,
name = it.name,
imageUrl = it.imageUrl,
twitter = it.twitter,
mediumPage = it.mediumPage,
)
}
)
}
.flowOn(ioDispatcher)
override suspend fun sync() = true
}

@ -51,7 +51,9 @@ class PopulatedEpisodeKtTest {
AuthorEntity( AuthorEntity(
id = 2, id = 2,
name = "name", name = "name",
imageUrl = "imageUrl" imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
) )
), ),
) )
@ -82,7 +84,9 @@ class PopulatedEpisodeKtTest {
Author( Author(
id = 2, id = 2,
name = "name", name = "name",
imageUrl = "imageUrl" imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
) )
), ),
), ),

@ -49,7 +49,9 @@ class PopulatedNewsResourceKtTest {
AuthorEntity( AuthorEntity(
id = 2, id = 2,
name = "name", name = "name",
imageUrl = "imageUrl" imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
) )
), ),
topics = listOf( topics = listOf(
@ -79,7 +81,9 @@ class PopulatedNewsResourceKtTest {
Author( Author(
id = 2, id = 2,
name = "name", name = "name",
imageUrl = "imageUrl" imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
) )
), ),
topics = listOf( topics = listOf(

@ -34,7 +34,9 @@ class NetworkEntityKtTest {
val networkModel = NetworkAuthor( val networkModel = NetworkAuthor(
id = 0, id = 0,
name = "Test", name = "Test",
imageUrl = "something" imageUrl = "something",
twitter = "twitter",
mediumPage = "mediumPage",
) )
val entity = networkModel.asEntity() val entity = networkModel.asEntity()

@ -0,0 +1,80 @@
/*
* Copyright 2022 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.repository
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.domain.model.asEntity
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork
import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert
import org.junit.Before
import org.junit.Test
class LocalAuthorsRepositoryTest {
private lateinit var subject: LocalAuthorsRepository
private lateinit var authorDao: AuthorDao
private lateinit var network: NiANetwork
@Before
fun setup() {
authorDao = TestAuthorDao()
network = TestNiaNetwork()
subject = LocalAuthorsRepository(
authorDao = authorDao,
network = network,
)
}
@Test
fun localAuthorsRepository_Authors_stream_is_backed_by_Authors_dao() =
runTest {
Assert.assertEquals(
authorDao.getAuthorEntitiesStream()
.first()
.map(AuthorEntity::asExternalModel),
subject.getAuthorsStream()
.first()
)
}
@Test
fun localAuthorsRepository_sync_pulls_from_network() =
runTest {
subject.sync()
val network = network.getAuthors()
.map(NetworkAuthor::asEntity)
val db = authorDao.getAuthorEntitiesStream()
.first()
Assert.assertEquals(
network.map(AuthorEntity::id),
db.map(AuthorEntity::id)
)
}
}

@ -32,6 +32,8 @@ class TestAuthorDao : AuthorDao {
id = 1, id = 1,
name = "Topic", name = "Topic",
imageUrl = "imageUrl", imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
) )
) )
) )

@ -110,7 +110,9 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
AuthorEntity( AuthorEntity(
id = 2, id = 2,
name = "name", name = "name",
imageUrl = "imageUrl" imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
) )
), ),
topics = listOf( topics = listOf(

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.domain.testdoubles
import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -33,6 +34,9 @@ class TestNiaNetwork : NiANetwork {
override suspend fun getTopics(itemsPerPage: Int): List<NetworkTopic> = override suspend fun getTopics(itemsPerPage: Int): List<NetworkTopic> =
networkJson.decodeFromString(FakeDataSource.topicsData) networkJson.decodeFromString(FakeDataSource.topicsData)
override suspend fun getAuthors(itemsPerPage: Int): List<NetworkAuthor> =
networkJson.decodeFromString(FakeDataSource.authors)
override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> = override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> =
networkJson.decodeFromString(FakeDataSource.data) networkJson.decodeFromString(FakeDataSource.data)
} }

@ -23,4 +23,6 @@ data class Author(
val id: Int, val id: Int,
val name: String, val name: String,
val imageUrl: String, val imageUrl: String,
val twitter: String,
val mediumPage: String,
) )

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.network package com.google.samples.apps.nowinandroid.core.network
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -25,5 +26,7 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
interface NiANetwork { interface NiANetwork {
suspend fun getTopics(itemsPerPage: Int = 200): List<NetworkTopic> suspend fun getTopics(itemsPerPage: Int = 200): List<NetworkTopic>
suspend fun getAuthors(itemsPerPage: Int = 200): List<NetworkAuthor>
suspend fun getNewsResources(itemsPerPage: Int = 200): List<NetworkNewsResource> suspend fun getNewsResources(itemsPerPage: Int = 200): List<NetworkNewsResource>
} }

@ -27,9 +27,9 @@ import org.intellij.lang.annotations.Language
object FakeDataSource { object FakeDataSource {
val sampleTopic = NetworkTopic( val sampleTopic = NetworkTopic(
id = 1, id = 1,
name = "UI", name = "UI",
shortDescription = "Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", shortDescription = "Material Design, Navigation, Text, Paging, Compose, 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!", 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!",
url = "url", url = "url",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=5d1d25a8-db1b-4cf1-9706-82ba0d133bf9" imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=5d1d25a8-db1b-4cf1-9706-82ba0d133bf9"
) )
@ -1638,4 +1638,472 @@ object FakeDataSource {
} }
] ]
""".trimIndent() """.trimIndent()
@Language("JSON")
val authors = """
[
{
"id": "1",
"name": "Márton Braun",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "2",
"name": "Greg Hartrell",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "3",
"name": "Simona Stojanovic",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "4",
"name": "Andrew Flynn",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "5",
"name": "Jon Boekenoogen",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "6",
"name": "Florina Muntenescu",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "7",
"name": "Lidia Gaymond",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "8",
"name": "Vicki Amin",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "9",
"name": "Marcel Pintó",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "10",
"name": "Krish Vitaldevara",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "11",
"name": "Gerry Fan",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "12",
"name": "Pietro Maggi",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "13",
"name": "Rohan Shah",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "14",
"name": "Dave Burke",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "15",
"name": "Meghan Mehta",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "16",
"name": "Anna Bernbaum",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "17",
"name": "Adarsh Fernando",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "18",
"name": "Madan Ankapura",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "19",
"name": "Kateryna Semenova",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "20",
"name": "Rahul Ravikumar",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "21",
"name": "Chris Craik",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "22",
"name": "Marcel Pintó Biescas",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "23",
"name": "Alex Vanyo",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "24",
"name": "Manuel Vicente Vivo",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "25",
"name": "Arjun Dayal",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "26",
"name": "Murat Yener",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "27",
"name": "Alex Saveau",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "28",
"name": "Paul Lammertsma",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "29",
"name": "Caren Chang",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "30",
"name": "Mayuri Khinvasara Khabya",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "31",
"name": "Romain Guy",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "32",
"name": "Chet Hasse",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "33",
"name": "Tor Norbye",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "34",
"name": "Nicole Laure",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "35",
"name": "Yigit Boyar",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "36",
"name": "Sean McQuillan",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "37",
"name": "Ben Weiss",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "38",
"name": "Chet Haase",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "39",
"name": "Carmen Jackson",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "40",
"name": "Manuel Vivo",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "41",
"name": "TJ Dahunsi",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "42",
"name": "Shailen Tuli",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "43",
"name": "Murat",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "44",
"name": "Kailiang Chen",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "45",
"name": "Meghan",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "46",
"name": "Jeremy Walker",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "47",
"name": "Don Turner",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "48",
"name": "Lilian Young",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "49",
"name": "Wenhung Teng",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "50",
"name": "Charcoal Chen",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "51",
"name": "Mike Yerou",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "52",
"name": "Peter Visontay",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "53",
"name": "Marcelo Hernandez",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "54",
"name": "Daniel Santiago",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "55",
"name": "Brad Corso",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "56",
"name": "Jonathan Koren",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "57",
"name": "Anna-Chiara Bellini",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "58",
"name": "Amanda Alexander",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "59",
"name": "Android Developers Backstage",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "60",
"name": "Nicole Borrelli",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "61",
"name": "Dan Saadati",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "62",
"name": "Nick Butcher",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "63",
"name": "Ian Lake",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "64",
"name": "Diana Wong",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "65",
"name": "Patricia Correa",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
},
{
"id": "66",
"name": "The Modern Android Development Team",
"mediumPage": "",
"twitter": "",
"imageUrl": ""
}
]
""".trimIndent()
} }

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.network.fake
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject import javax.inject.Inject
@ -43,4 +44,9 @@ class FakeNiANetwork @Inject constructor(
withContext(ioDispatcher) { withContext(ioDispatcher) {
networkJson.decodeFromString(FakeDataSource.data) networkJson.decodeFromString(FakeDataSource.data)
} }
override suspend fun getAuthors(itemsPerPage: Int): List<NetworkAuthor> =
withContext(ioDispatcher) {
networkJson.decodeFromString(FakeDataSource.authors)
}
} }

@ -27,4 +27,6 @@ data class NetworkAuthor(
val id: Int, val id: Int,
val name: String, val name: String,
val imageUrl: String, val imageUrl: String,
val twitter: String,
val mediumPage: String,
) )

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.retrofit package com.google.samples.apps.nowinandroid.core.network.retrofit
import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
@ -40,6 +41,11 @@ private interface RetrofitNiANetworkApi {
@Query("pageSize") itemsPerPage: Int, @Query("pageSize") itemsPerPage: Int,
): NetworkResponse<List<NetworkTopic>> ): NetworkResponse<List<NetworkTopic>>
@GET(value = "authors")
suspend fun getAuthors(
@Query("pageSize") itemsPerPage: Int,
): NetworkResponse<List<NetworkAuthor>>
@GET(value = "newsresources") @GET(value = "newsresources")
suspend fun getNewsResources( suspend fun getNewsResources(
@Query("pageSize") itemsPerPage: Int, @Query("pageSize") itemsPerPage: Int,
@ -83,6 +89,9 @@ class RetrofitNiANetwork @Inject constructor(
override suspend fun getTopics(itemsPerPage: Int): List<NetworkTopic> = override suspend fun getTopics(itemsPerPage: Int): List<NetworkTopic> =
networkApi.getTopics(itemsPerPage = itemsPerPage).data networkApi.getTopics(itemsPerPage = itemsPerPage).data
override suspend fun getAuthors(itemsPerPage: Int): List<NetworkAuthor> =
networkApi.getAuthors(itemsPerPage = itemsPerPage).data
override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> = override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> =
networkApi.getNewsResources(itemsPerPage = itemsPerPage).data networkApi.getNewsResources(itemsPerPage = itemsPerPage).data
} }

@ -284,7 +284,9 @@ private val newsResource = NewsResource(
Author( Author(
id = 1, id = 1,
name = "Name", name = "Name",
imageUrl = "" imageUrl = "",
twitter = "",
mediumPage = "",
) )
), ),
topics = listOf( topics = listOf(

@ -24,6 +24,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.sync.SyncRepository import com.google.samples.apps.nowinandroid.sync.SyncRepository
@ -32,6 +33,9 @@ import com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
/** /**
* Syncs the data layer by delegating to the appropriate repository instances with * Syncs the data layer by delegating to the appropriate repository instances with
@ -44,14 +48,21 @@ class SyncWorker @AssistedInject constructor(
private val syncRepository: SyncRepository, private val syncRepository: SyncRepository,
private val topicRepository: TopicsRepository, private val topicRepository: TopicsRepository,
private val newsRepository: NewsRepository, private val newsRepository: NewsRepository,
private val authorsRepository: AuthorsRepository,
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
override suspend fun getForegroundInfo(): ForegroundInfo = override suspend fun getForegroundInfo(): ForegroundInfo =
appContext.syncForegroundInfo() appContext.syncForegroundInfo()
override suspend fun doWork(): Result = override suspend fun doWork(): Result = coroutineScope {
// First sync the repositories // First sync the repositories in parallel
when (topicRepository.sync() && newsRepository.sync()) { val syncedSuccessfully = awaitAll(
async { topicRepository.sync() },
async { authorsRepository.sync() },
async { newsRepository.sync() },
).all { it }
when (syncedSuccessfully) {
// Sync ran successfully, notify the SyncRepository that sync has been run // Sync ran successfully, notify the SyncRepository that sync has been run
true -> { true -> {
syncRepository.notifyFirstTimeSyncRun() syncRepository.notifyFirstTimeSyncRun()
@ -59,6 +70,7 @@ class SyncWorker @AssistedInject constructor(
} }
false -> Result.retry() false -> Result.retry()
} }
}
companion object { companion object {
private const val SyncInterval = 1L private const val SyncInterval = 1L

Loading…
Cancel
Save