Skip to content

Commit 73a3872

Browse files
authored
Merge pull request #649 from android/tj/backend-requested-sync
Wire up backend requested sync
2 parents adc9381 + 6b834b6 commit 73a3872

File tree

12 files changed

+294
-63
lines changed

12 files changed

+294
-63
lines changed

core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsRes
2727
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
2828
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
2929
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
30+
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
3031
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
3132
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
3233
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
@@ -45,6 +46,7 @@ private const val SYNC_BATCH_SIZE = 40
4546
* Reads are exclusively from local storage to support offline access.
4647
*/
4748
class OfflineFirstNewsRepository @Inject constructor(
49+
private val niaPreferencesDataSource: NiaPreferencesDataSource,
4850
private val newsResourceDao: NewsResourceDao,
4951
private val topicDao: TopicDao,
5052
private val network: NiaNetworkDataSource,
@@ -72,16 +74,27 @@ class OfflineFirstNewsRepository @Inject constructor(
7274
},
7375
modelDeleter = newsResourceDao::deleteNewsResources,
7476
modelUpdater = { changedIds ->
77+
val userData = niaPreferencesDataSource.userData.first()
78+
val hasOnboarded = userData.shouldHideOnboarding
79+
val followedTopicIds = userData.followedTopics
80+
7581
// TODO: Make this more efficient, there is no need to retrieve populated
7682
// news resources when all that's needed are the ids
77-
val existingNewsResourceIds = newsResourceDao.getNewsResources(
78-
useFilterNewsIds = true,
79-
filterNewsIds = changedIds.toSet(),
80-
)
81-
.first()
82-
.map { it.entity.id }
83-
.toSet()
83+
val existingNewsResourceIdsThatHaveChanged = when {
84+
hasOnboarded -> newsResourceDao.getNewsResources(
85+
useFilterTopicIds = true,
86+
filterTopicIds = followedTopicIds,
87+
useFilterNewsIds = true,
88+
filterNewsIds = changedIds.toSet(),
89+
)
90+
.first()
91+
.map { it.entity.id }
92+
.toSet()
93+
// No need to retrieve anything if notifications won't be sent
94+
else -> emptySet()
95+
}
8496

97+
// Obtain the news resources which have changed from the network and upsert them locally
8598
changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
8699
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
87100

@@ -106,19 +119,18 @@ class OfflineFirstNewsRepository @Inject constructor(
106119
)
107120
}
108121

109-
val addedNewsResources = newsResourceDao.getNewsResources(
110-
useFilterNewsIds = true,
111-
filterNewsIds = changedIds.toSet(),
112-
)
113-
.first()
114-
.filter { !existingNewsResourceIds.contains(it.entity.id) }
115-
.map(PopulatedNewsResource::asExternalModel)
122+
if (hasOnboarded) {
123+
val addedNewsResources = newsResourceDao.getNewsResources(
124+
useFilterTopicIds = true,
125+
filterTopicIds = followedTopicIds,
126+
useFilterNewsIds = true,
127+
filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged,
128+
)
129+
.first()
130+
.map(PopulatedNewsResource::asExternalModel)
116131

117-
// TODO: Define business logic for notifications on first time sync.
118-
// we probably do not want to send notifications on first install.
119-
// We can easily check if the change list version is 0 and not send notifications
120-
// if it is.
121-
if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources)
132+
if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources)
133+
}
122134
},
123135
)
124136
}

core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
3434
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
3535
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
3636
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
37+
import com.google.samples.apps.nowinandroid.core.model.data.Topic
3738
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
3839
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
3940
import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier
@@ -46,13 +47,16 @@ import org.junit.Rule
4647
import org.junit.Test
4748
import org.junit.rules.TemporaryFolder
4849
import kotlin.test.assertEquals
50+
import kotlin.test.assertTrue
4951

5052
class OfflineFirstNewsRepositoryTest {
5153

5254
private val testScope = TestScope(UnconfinedTestDispatcher())
5355

5456
private lateinit var subject: OfflineFirstNewsRepository
5557

58+
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
59+
5660
private lateinit var newsResourceDao: TestNewsResourceDao
5761

5862
private lateinit var topicDao: TestTopicDao
@@ -68,17 +72,19 @@ class OfflineFirstNewsRepositoryTest {
6872

6973
@Before
7074
fun setup() {
75+
niaPreferencesDataSource = NiaPreferencesDataSource(
76+
tmpFolder.testUserPreferencesDataStore(testScope),
77+
)
7178
newsResourceDao = TestNewsResourceDao()
7279
topicDao = TestTopicDao()
7380
network = TestNiaNetworkDataSource()
7481
notifier = TestNotifier()
7582
synchronizer = TestSynchronizer(
76-
NiaPreferencesDataSource(
77-
tmpFolder.testUserPreferencesDataStore(testScope),
78-
),
83+
niaPreferencesDataSource,
7984
)
8085

8186
subject = OfflineFirstNewsRepository(
87+
niaPreferencesDataSource = niaPreferencesDataSource,
8288
newsResourceDao = newsResourceDao,
8389
topicDao = topicDao,
8490
network = network,
@@ -130,6 +136,8 @@ class OfflineFirstNewsRepositoryTest {
130136
@Test
131137
fun offlineFirstNewsRepository_sync_pulls_from_network() =
132138
testScope.runTest {
139+
// User has not onboarded
140+
niaPreferencesDataSource.setShouldHideOnboarding(false)
133141
subject.syncWith(synchronizer)
134142

135143
val newsResourcesFromNetwork = network.getNewsResources()
@@ -151,16 +159,16 @@ class OfflineFirstNewsRepositoryTest {
151159
actual = synchronizer.getChangeListVersions().newsResourceVersion,
152160
)
153161

154-
// Notifier should have been called with new news resources
155-
assertEquals(
156-
expected = newsResourcesFromDb.map(NewsResource::id).sorted(),
157-
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
158-
)
162+
// Notifier should not have been called
163+
assertTrue(notifier.addedNewsResources.isEmpty())
159164
}
160165

161166
@Test
162167
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
163168
testScope.runTest {
169+
// User has not onboarded
170+
niaPreferencesDataSource.setShouldHideOnboarding(false)
171+
164172
val newsResourcesFromNetwork = network.getNewsResources()
165173
.map(NetworkNewsResource::asEntity)
166174
.map(NewsResourceEntity::asExternalModel)
@@ -198,17 +206,16 @@ class OfflineFirstNewsRepositoryTest {
198206
actual = synchronizer.getChangeListVersions().newsResourceVersion,
199207
)
200208

201-
// Notifier should have been called with news resources from network that are not
202-
// deleted
203-
assertEquals(
204-
expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
205-
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
206-
)
209+
// Notifier should not have been called
210+
assertTrue(notifier.addedNewsResources.isEmpty())
207211
}
208212

209213
@Test
210214
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =
211215
testScope.runTest {
216+
// User has not onboarded
217+
niaPreferencesDataSource.setShouldHideOnboarding(false)
218+
212219
// Set news version to 7
213220
synchronizer.updateChangeListVersions {
214221
copy(newsResourceVersion = 7)
@@ -244,11 +251,8 @@ class OfflineFirstNewsRepositoryTest {
244251
actual = synchronizer.getChangeListVersions().newsResourceVersion,
245252
)
246253

247-
// Notifier should have been called with only added news resources from network
248-
assertEquals(
249-
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
250-
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
251-
)
254+
// Notifier should not have been called
255+
assertTrue(notifier.addedNewsResources.isEmpty())
252256
}
253257

254258
@Test
@@ -283,4 +287,70 @@ class OfflineFirstNewsRepositoryTest {
283287
.sortedBy(NewsResourceTopicCrossRef::toString),
284288
)
285289
}
290+
291+
@Test
292+
fun offlineFirstNewsRepository_sends_notifications_for_newly_synced_news_that_is_followed() =
293+
testScope.runTest {
294+
// User has onboarded
295+
niaPreferencesDataSource.setShouldHideOnboarding(true)
296+
297+
val networkNewsResources = network.getNewsResources()
298+
299+
// Follow roughly half the topics
300+
val followedTopicIds = networkNewsResources
301+
.flatMap(NetworkNewsResource::topicEntityShells)
302+
.mapNotNull { topic ->
303+
when (topic.id.chars().sum() % 2) {
304+
0 -> topic.id
305+
else -> null
306+
}
307+
}
308+
.toSet()
309+
310+
// Set followed topics
311+
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
312+
313+
subject.syncWith(synchronizer)
314+
315+
val followedNewsResourceIdsFromNetwork = networkNewsResources
316+
.filter { (it.topics intersect followedTopicIds).isNotEmpty() }
317+
.map(NetworkNewsResource::id)
318+
.sorted()
319+
320+
// Notifier should have been called with only news resources that have topics
321+
// that the user follows
322+
assertEquals(
323+
expected = followedNewsResourceIdsFromNetwork,
324+
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
325+
)
326+
}
327+
328+
@Test
329+
fun offlineFirstNewsRepository_does_not_send_notifications_for_existing_news_resources() =
330+
testScope.runTest {
331+
// User has onboarded
332+
niaPreferencesDataSource.setShouldHideOnboarding(true)
333+
334+
val networkNewsResources = network.getNewsResources()
335+
.map(NetworkNewsResource::asEntity)
336+
337+
val newsResources = networkNewsResources
338+
.map(NewsResourceEntity::asExternalModel)
339+
340+
// Prepopulate dao with news resources
341+
newsResourceDao.upsertNewsResources(networkNewsResources)
342+
343+
val followedTopicIds = newsResources
344+
.flatMap(NewsResource::topics)
345+
.map(Topic::id)
346+
.toSet()
347+
348+
// Follow all topics
349+
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
350+
351+
subject.syncWith(synchronizer)
352+
353+
// Notifier should not have been called bc all news resources existed previously
354+
assertTrue(notifier.addedNewsResources.isEmpty())
355+
}
286356
}

core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ class TestNewsResourceDao : NewsResourceDao {
4747
filterNewsIds: Set<String>,
4848
): Flow<List<PopulatedNewsResource>> =
4949
entitiesStateFlow
50-
.map { it.map(NewsResourceEntity::asPopulatedNewsResource) }
50+
.map { newsResourceEntities ->
51+
newsResourceEntities.map { entity ->
52+
entity.asPopulatedNewsResource(topicCrossReferences)
53+
}
54+
}
5155
.map { resources ->
5256
var result = resources
5357
if (useFilterTopicIds) {
@@ -78,10 +82,6 @@ class TestNewsResourceDao : NewsResourceDao {
7882
return entities.map { it.id.toLong() }
7983
}
8084

81-
override suspend fun updateNewsResources(entities: List<NewsResourceEntity>) {
82-
throw NotImplementedError("Unused in tests")
83-
}
84-
8585
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
8686
entitiesStateFlow.update { oldValues ->
8787
// New values come first so they overwrite old values
@@ -109,16 +109,20 @@ class TestNewsResourceDao : NewsResourceDao {
109109
}
110110
}
111111

112-
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource(
112+
private fun NewsResourceEntity.asPopulatedNewsResource(
113+
topicCrossReferences: List<NewsResourceTopicCrossRef>,
114+
) = PopulatedNewsResource(
113115
entity = this,
114-
topics = listOf(
115-
TopicEntity(
116-
id = filteredInterestsIds.random(),
117-
name = "name",
118-
shortDescription = "short description",
119-
longDescription = "long description",
120-
url = "URL",
121-
imageUrl = "image URL",
122-
),
123-
),
116+
topics = topicCrossReferences
117+
.filter { it.newsResourceId == id }
118+
.map { newsResourceTopicCrossRef ->
119+
TopicEntity(
120+
id = newsResourceTopicCrossRef.topicId,
121+
name = "name",
122+
shortDescription = "short description",
123+
longDescription = "long description",
124+
url = "URL",
125+
imageUrl = "image URL",
126+
)
127+
},
124128
)

core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import androidx.room.Insert
2121
import androidx.room.OnConflictStrategy
2222
import androidx.room.Query
2323
import androidx.room.Transaction
24-
import androidx.room.Update
2524
import androidx.room.Upsert
2625
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
2726
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@@ -72,12 +71,6 @@ interface NewsResourceDao {
7271
@Insert(onConflict = OnConflictStrategy.IGNORE)
7372
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long>
7473

75-
/**
76-
* Updates [entities] in the db that match the primary key, and no-ops if they don't
77-
*/
78-
@Update
79-
suspend fun updateNewsResources(entities: List<NewsResourceEntity>)
80-
8174
/**
8275
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
8376
*/

sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt renamed to sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package com.google.samples.apps.nowinandroid.sync.di
1818

1919
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
20+
import com.google.samples.apps.nowinandroid.sync.status.StubSyncSubscriber
21+
import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber
2022
import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager
2123
import dagger.Binds
2224
import dagger.Module
@@ -30,4 +32,9 @@ interface SyncModule {
3032
fun bindsSyncStatusMonitor(
3133
syncStatusMonitor: WorkManagerSyncManager,
3234
): SyncManager
35+
36+
@Binds
37+
fun bindsSyncSubscriber(
38+
syncSubscriber: StubSyncSubscriber,
39+
): SyncSubscriber
3340
}

sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.work.ForegroundInfo
2727
import androidx.work.NetworkType
2828
import com.google.samples.apps.nowinandroid.sync.R
2929

30+
const val SYNC_TOPIC = "sync"
3031
private const val SyncNotificationId = 0
3132
private const val SyncNotificationChannelID = "SyncNotificationChannel"
3233

sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,10 @@ package com.google.samples.apps.nowinandroid.sync.services
1919
import com.google.firebase.messaging.FirebaseMessagingService
2020
import com.google.firebase.messaging.RemoteMessage
2121
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
22+
import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC
2223
import dagger.hilt.android.AndroidEntryPoint
2324
import javax.inject.Inject
2425

25-
private const val SYNC_TOPIC = "sync"
26-
2726
@AndroidEntryPoint
2827
class SyncNotificationsService : FirebaseMessagingService() {
2928

0 commit comments

Comments
 (0)