Merge branch 'main' into jr/track-viewed

pull/595/head
James Rose 2 years ago
commit 050db2cb72

@ -27,7 +27,7 @@ jobs:
- name: Run instrumented tests with GMD - name: Run instrumented tests with GMD
run: ./gradlew cleanManagedDevices --unused-only && run: ./gradlew cleanManagedDevices --unused-only &&
./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true --info -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
- name: Upload test reports - name: Upload test reports
if: success() || failure() if: success() || failure()

@ -65,9 +65,8 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:ui"))
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:ui"))
implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.androidx.activity.compose)
} }

@ -68,13 +68,13 @@ android {
} }
dependencies { dependencies {
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.test.core) implementation(libs.androidx.test.core)
implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.espresso.core)
implementation(libs.androidx.test.ext) implementation(libs.androidx.test.ext)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.rules) implementation(libs.androidx.test.rules)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.uiautomator) implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.benchmark.macro)
} }
androidComponents { androidComponents {

@ -27,9 +27,9 @@ java {
dependencies { dependencies {
compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.firebase.crashlytics.gradle) compileOnly(libs.firebase.crashlytics.gradle)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
} }

Binary file not shown.

@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -24,10 +24,9 @@ android {
} }
dependencies { dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(platform(libs.firebase.bom))
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics) implementation(libs.firebase.analytics)
implementation(libs.kotlinx.coroutines.android)
} }

@ -25,24 +25,24 @@ android {
testOptions { testOptions {
unitTests { unitTests {
isIncludeAndroidResources = true isIncludeAndroidResources = true
isReturnDefaultValues = true
} }
} }
} }
dependencies { dependencies {
implementation(project(":core:analytics"))
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model"))
implementation(project(":core:database")) implementation(project(":core:database"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:model"))
implementation(project(":core:network")) implementation(project(":core:network"))
implementation(project(":core:analytics")) implementation(project(":core:notifications"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
}
testImplementation(project(":core:datastore-test"))
testImplementation(project(":core:testing"))
}

@ -30,7 +30,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
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.notifications.Notifier
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@ -46,6 +48,7 @@ class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
private val notifier: Notifier,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources( override fun getNewsResources(
@ -69,6 +72,16 @@ class OfflineFirstNewsRepository @Inject constructor(
}, },
modelDeleter = newsResourceDao::deleteNewsResources, modelDeleter = newsResourceDao::deleteNewsResources,
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
// TODO: Make this more efficient, there is no need to retrieve populated
// news resources when all that's needed are the ids
val existingNewsResourceIds = newsResourceDao.getNewsResources(
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
.first()
.map { it.entity.id }
.toSet()
changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
val networkNewsResources = network.getNewsResources(ids = chunkedIds) val networkNewsResources = network.getNewsResources(ids = chunkedIds)
@ -92,6 +105,20 @@ class OfflineFirstNewsRepository @Inject constructor(
.flatten(), .flatten(),
) )
} }
val addedNewsResources = newsResourceDao.getNewsResources(
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
.first()
.filter { !existingNewsResourceIds.contains(it.entity.id) }
.map(PopulatedNewsResource::asExternalModel)
// TODO: Define business logic for notifications on first time sync.
// we probably do not want to send notifications on first install.
// We can easily check if the change list version is 0 and not send notifications
// if it is.
if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources)
}, },
) )
} }

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
/** /**
* Reports on if synchronization is in progress * Reports on if synchronization is in progress
*/ */
interface SyncStatusMonitor { interface SyncManager {
val isSyncing: Flow<Boolean> val isSyncing: Flow<Boolean>
fun requestSync()
} }

@ -36,6 +36,7 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
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.testing.notifications.TestNotifier
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -58,6 +59,8 @@ class OfflineFirstNewsRepositoryTest {
private lateinit var network: TestNiaNetworkDataSource private lateinit var network: TestNiaNetworkDataSource
private lateinit var notifier: TestNotifier
private lateinit var synchronizer: Synchronizer private lateinit var synchronizer: Synchronizer
@get:Rule @get:Rule
@ -68,6 +71,7 @@ class OfflineFirstNewsRepositoryTest {
newsResourceDao = TestNewsResourceDao() newsResourceDao = TestNewsResourceDao()
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
notifier = TestNotifier()
synchronizer = TestSynchronizer( synchronizer = TestSynchronizer(
NiaPreferencesDataSource( NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore(testScope), tmpFolder.testUserPreferencesDataStore(testScope),
@ -78,6 +82,7 @@ class OfflineFirstNewsRepositoryTest {
newsResourceDao = newsResourceDao, newsResourceDao = newsResourceDao,
topicDao = topicDao, topicDao = topicDao,
network = network, network = network,
notifier = notifier,
) )
} }
@ -145,6 +150,12 @@ class OfflineFirstNewsRepositoryTest {
expected = network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should have been called with new news resources
assertEquals(
expected = newsResourcesFromDb.map(NewsResource::id).sorted(),
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
)
} }
@Test @Test
@ -186,6 +197,13 @@ class OfflineFirstNewsRepositoryTest {
expected = network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should have been called with news resources from network that are not
// deleted
assertEquals(
expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
)
} }
@Test @Test
@ -225,6 +243,12 @@ class OfflineFirstNewsRepositoryTest {
expected = changeList.last().changeListVersion, expected = changeList.last().changeListVersion,
actual = synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should have been called with only added news resources from network
assertEquals(
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
)
} }
@Test @Test

@ -24,8 +24,8 @@ android {
dependencies { dependencies {
api(project(":core:datastore")) api(project(":core:datastore"))
api(libs.androidx.dataStore.core)
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:testing")) implementation(project(":core:testing"))
api(libs.androidx.dataStore.core)
} }

@ -33,6 +33,11 @@ android {
consumerProguardFiles("consumer-proguard-rules.pro") consumerProguardFiles("consumer-proguard-rules.pro")
} }
namespace = "com.google.samples.apps.nowinandroid.core.datastore" namespace = "com.google.samples.apps.nowinandroid.core.datastore"
testOptions {
unitTests {
isReturnDefaultValues = true
}
}
} }
// Setup protobuf configuration, generating lite Java and Kotlin classes // Setup protobuf configuration, generating lite Java and Kotlin classes
@ -57,12 +62,10 @@ protobuf {
dependencies { dependencies {
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model")) implementation(project(":core:model"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.dataStore.core) implementation(libs.androidx.dataStore.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
testImplementation(project(":core:datastore-test"))
testImplementation(project(":core:testing"))
} }

@ -30,16 +30,20 @@ android {
} }
dependencies { dependencies {
implementation(libs.androidx.core.ktx) lintPublish(project(":lint"))
implementation(libs.coil.kt.compose)
api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material.iconsExtended)
api(libs.androidx.compose.material3) api(libs.androidx.compose.material3)
debugApi(libs.androidx.compose.ui.tooling) api(libs.androidx.compose.runtime)
api(libs.androidx.compose.ui.tooling.preview) api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.compose.ui.util) api(libs.androidx.compose.ui.util)
api(libs.androidx.compose.runtime)
lintPublish(project(":lint")) debugApi(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt.compose)
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
} }

@ -24,15 +24,13 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(libs.hilt.android)
testImplementation(project(":core:testing"))
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler) kapt(libs.hilt.compiler)
testImplementation(project(":core:testing"))
} }

@ -41,17 +41,14 @@ secrets {
dependencies { dependencies {
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(libs.coil.kt)
testImplementation(project(":core:testing")) implementation(libs.coil.kt.svg)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.retrofit.core) implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlin.serialization) implementation(libs.retrofit.kotlin.serialization)
implementation(libs.coil.kt) testImplementation(project(":core:testing"))
implementation(libs.coil.kt.svg)
} }

@ -0,0 +1,35 @@
/*
* 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.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.notifications"
}
dependencies {
implementation(project(":core:model"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.cloud.messaging)
}

@ -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.notifications
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class NotificationsModule {
@Binds
abstract fun bindNotifier(
notifier: NoOpNotifier,
): Notifier
}

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"/>

@ -0,0 +1,32 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.notifications
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import javax.inject.Inject
import javax.inject.Singleton
/**
* Implementation of [Notifier] that displays notifications in the system tray.
*/
@Singleton
class AndroidSystemNotifier @Inject constructor() : Notifier {
override fun onNewsAdded(newsResources: List<NewsResource>) {
// TODO, create notification and display to the user
}
}

@ -0,0 +1,27 @@
/*
* 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.notifications
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import javax.inject.Inject
/**
* Implementation of [Notifier] which does nothing. Useful for tests and previews.
*/
class NoOpNotifier @Inject constructor() : Notifier {
override fun onNewsAdded(newsResources: List<NewsResource>) = Unit
}

@ -0,0 +1,26 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.notifications
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
/**
* Interface for creating notifications in the app
*/
interface Notifier {
fun onNewsAdded(newsResources: List<NewsResource>)
}

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<string name="sync_notification_title">Now in Android</string>
<string name="sync_notification_channel_name">Sync</string>
<string name="sync_notification_channel_description">Background tasks for Now in Android</string>
</resources>

@ -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.notifications
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class NotificationsModule {
@Binds
abstract fun bindNotifier(
notifier: AndroidSystemNotifier,
): Notifier
}

@ -24,23 +24,22 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:common")) api(libs.androidx.compose.ui.test)
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:model"))
implementation(libs.kotlinx.datetime)
api(libs.junit4)
api(libs.androidx.test.core) api(libs.androidx.test.core)
api(libs.kotlinx.coroutines.test)
api(libs.turbine)
api(libs.androidx.test.espresso.core) api(libs.androidx.test.espresso.core)
api(libs.androidx.test.runner)
api(libs.androidx.test.rules) api(libs.androidx.test.rules)
api(libs.androidx.compose.ui.test) api(libs.androidx.test.runner)
api(libs.hilt.android.testing) api(libs.hilt.android.testing)
api(libs.junit4)
api(libs.kotlinx.coroutines.test)
api(libs.turbine)
debugApi(libs.androidx.compose.ui.testManifest) debugApi(libs.androidx.compose.ui.testManifest)
implementation(project(":core:common"))
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:model"))
implementation(project(":core:notifications"))
implementation(libs.kotlinx.datetime)
} }

@ -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.testing.notifications
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.notifications.Notifier
/**
* Aggregates news resources that have been notified for addition
*/
class TestNotifier : Notifier {
private val mutableAddedNewResources = mutableListOf<List<NewsResource>>()
val addedNewsResources: List<List<NewsResource>> = mutableAddedNewResources
override fun onNewsAdded(newsResources: List<NewsResource>) {
mutableAddedNewResources.add(newsResources)
}
}

@ -16,16 +16,20 @@
package com.google.samples.apps.nowinandroid.core.testing.util package com.google.samples.apps.nowinandroid.core.testing.util
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class TestSyncStatusMonitor : SyncStatusMonitor { class TestSyncManager : SyncManager {
private val syncStatusFlow = MutableStateFlow(false) private val syncStatusFlow = MutableStateFlow(false)
override val isSyncing: Flow<Boolean> = syncStatusFlow override val isSyncing: Flow<Boolean> = syncStatusFlow
override fun requestSync() {
TODO("Not yet implemented")
}
/** /**
* A test-only API to set the sync status from tests. * A test-only API to set the sync status from tests.
*/ */

@ -27,28 +27,28 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:model"))
implementation(project(":core:domain"))
implementation(project(":core:analytics"))
implementation(libs.androidx.browser)
implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt)
implementation(libs.coil.kt.compose)
implementation(libs.kotlinx.datetime)
api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material.iconsExtended)
api(libs.androidx.compose.material3) api(libs.androidx.compose.material3)
debugApi(libs.androidx.compose.ui.tooling)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.compose.ui.util)
api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime)
api(libs.androidx.compose.runtime.livedata) api(libs.androidx.compose.runtime.livedata)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.compose.ui.util)
api(libs.androidx.metrics) api(libs.androidx.metrics)
api(libs.androidx.tracing.ktx) api(libs.androidx.tracing.ktx)
debugApi(libs.androidx.compose.ui.tooling)
implementation(project(":core:analytics"))
implementation(project(":core:designsystem"))
implementation(project(":core:domain"))
implementation(project(":core:model"))
implementation(libs.androidx.browser)
implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt)
implementation(libs.coil.kt.compose)
implementation(libs.kotlinx.datetime)
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
} }

@ -39,7 +39,7 @@ fun AnalyticsHelper.logScreenView(screenName: String) {
) )
} }
fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String, newsResourceTitle: String) { fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String) {
logEvent( logEvent(
event = AnalyticsEvent( event = AnalyticsEvent(
type = "news_resource_opened", type = "news_resource_opened",

@ -21,6 +21,7 @@ import android.net.Uri
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyGridScope
@ -31,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
@ -68,7 +70,6 @@ fun LazyGridScope.newsFeed(
onClick = { onClick = {
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id, newsResourceId = userNewsResource.id,
newsResourceTitle = userNewsResource.title,
) )
launchCustomChromeTab(context, resourceUrl, backgroundColor) launchCustomChromeTab(context, resourceUrl, backgroundColor)
onNewsResourceViewed(userNewsResource.id) onNewsResourceViewed(userNewsResource.id)
@ -81,6 +82,7 @@ fun LazyGridScope.newsFeed(
) )
}, },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
modifier = Modifier.padding(horizontal = 8.dp),
) )
} }
} }

@ -37,6 +37,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -328,16 +329,20 @@ private fun ExpandedNewsResourcePreview(
@PreviewParameter(UserNewsResourcePreviewParameterProvider::class) @PreviewParameter(UserNewsResourcePreviewParameterProvider::class)
userNewsResources: List<UserNewsResource>, userNewsResources: List<UserNewsResource>,
) { ) {
NiaTheme { CompositionLocalProvider(
Surface { LocalInspectionMode provides true,
NewsResourceCardExpanded( ) {
userNewsResource = userNewsResources[0], NiaTheme {
isBookmarked = true, Surface {
hasBeenViewed = false, NewsResourceCardExpanded(
onToggleBookmark = {}, userNewsResource = userNewsResources[0],
onClick = {}, isBookmarked = true,
onTopicClick = {}, hasBeenViewed = false,
) onToggleBookmark = {},
onClick = {},
onTopicClick = {},
)
}
} }
} }
} }

@ -58,7 +58,6 @@ fun LazyListScope.userNewsResourceCardItems(
onClick = { onClick = {
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id, newsResourceId = userNewsResource.id,
newsResourceTitle = userNewsResource.title,
) )
when (onItemClick) { when (onItemClick) {
null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) null -> launchCustomChromeTab(context, resourceUrl, backgroundColor)

@ -163,7 +163,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
contentDescription = null, contentDescription = null,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(48.dp))
Text( Text(
text = stringResource(id = R.string.bookmarks_empty_error), text = stringResource(id = R.string.bookmarks_empty_error),

@ -27,7 +27,7 @@ android {
} }
dependencies { dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.activity.compose)
} }

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
@ -56,13 +57,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -72,7 +71,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace import androidx.compose.ui.util.trace
import androidx.core.view.doOnPreDraw
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
@ -127,23 +125,8 @@ internal fun ForYouScreen(
val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading
val isFeedLoading = feedState is NewsFeedUiState.Loading val isFeedLoading = feedState is NewsFeedUiState.Loading
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose. // This code should be called when the UI is ready for use and relates to Time To Full Display.
// This code should be called when the UI is ready for use ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading }
// and relates to Time To Full Display.
// TODO replace with ReportDrawnWhen { } once androidx.activity-compose 1.7.0 is used (currently alpha)
if (!isSyncing && !isOnboardingLoading && !isFeedLoading) {
val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope.
// Activity.reportFullyDrawn() has its own check for this
// and is safe to call multiple times though.
LaunchedEffect(Unit) {
// We're leveraging the fact, that the current view is directly set as content of Activity.
val activity = localView.context as? Activity ?: return@LaunchedEffect
// To be sure not to call in the middle of a frame draw.
localView.doOnPreDraw { activity.reportFullyDrawn() }
}
}
val state = rememberLazyGridState() val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "forYou:feed") TrackScrollJank(scrollableState = state, stateName = "forYou:feed")
@ -250,7 +233,7 @@ private fun LazyGridScope.onboarding(
text = stringResource(R.string.onboarding_guidance_subtitle), text = stringResource(R.string.onboarding_guidance_subtitle),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 8.dp, start = 16.dp, end = 16.dp), .padding(top = 8.dp, start = 24.dp, end = 24.dp),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@ -268,8 +251,9 @@ private fun LazyGridScope.onboarding(
onClick = saveFollowedTopics, onClick = saveFollowedTopics,
enabled = onboardingUiState.isDismissable, enabled = onboardingUiState.isDismissable,
modifier = Modifier modifier = Modifier
.padding(horizontal = 40.dp) .padding(horizontal = 24.dp)
.width(364.dp), .widthIn(364.dp)
.fillMaxWidth(),
) { ) {
Text( Text(
text = stringResource(R.string.done), text = stringResource(R.string.done),
@ -458,7 +442,8 @@ fun ForYouScreenTopicSelection(
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Shown( onboardingUiState = OnboardingUiState.Shown(
topics = userNewsResources.flatMap { news -> news.followableTopics }, topics = userNewsResources.flatMap { news -> news.followableTopics }
.distinctBy { it.topic.id },
), ),
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = userNewsResources, feed = userNewsResources,

@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -35,7 +35,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
syncStatusMonitor: SyncStatusMonitor, syncManager: SyncManager,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
getFollowableTopics: GetFollowableTopicsUseCase, getFollowableTopics: GetFollowableTopicsUseCase,
@ -44,7 +44,7 @@ class ForYouViewModel @Inject constructor(
private val shouldShowOnboarding: Flow<Boolean> = private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userData.map { !it.shouldHideOnboarding } userDataRepository.userData.map { !it.shouldHideOnboarding }
val isSyncing = syncStatusMonitor.isSyncing val isSyncing = syncManager.isSyncing
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
@ -52,7 +52,7 @@ class ForYouViewModel @Inject constructor(
) )
val feedState: StateFlow<NewsFeedUiState> = val feedState: StateFlow<NewsFeedUiState> =
userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() userNewsResourceRepository.observeAllForFollowedTopics()
.map(NewsFeedUiState::Success) .map(NewsFeedUiState::Success)
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,

@ -30,7 +30,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,7 +52,7 @@ class ForYouViewModelTest {
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val networkMonitor = TestNetworkMonitor() private val networkMonitor = TestNetworkMonitor()
private val syncStatusMonitor = TestSyncStatusMonitor() private val syncManager = TestSyncManager()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
@ -70,7 +70,7 @@ class ForYouViewModelTest {
@Before @Before
fun setup() { fun setup() {
viewModel = ForYouViewModel( viewModel = ForYouViewModel(
syncStatusMonitor = syncStatusMonitor, syncManager = syncManager,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
@ -106,7 +106,7 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest { fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest {
syncStatusMonitor.setSyncing(true) syncManager.setSyncing(true)
val collectJob = val collectJob =
launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() } launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }

@ -65,7 +65,7 @@ fun InterestsItem(
.padding(vertical = itemSeparation), .padding(vertical = itemSeparation),
) { ) {
InterestsIcon(topicImageUrl, iconModifier.size(64.dp)) InterestsIcon(topicImageUrl, iconModifier.size(64.dp))
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(24.dp))
InterestContent(name, description) InterestContent(name, description)
} }
NiaIconToggleButton( NiaIconToggleButton(

@ -38,9 +38,9 @@ fun TopicsTabContent(
) { ) {
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.padding(horizontal = 16.dp) .padding(horizontal = 24.dp)
.testTag("interests:topics"), .testTag("interests:topics"),
contentPadding = PaddingValues(top = 8.dp), contentPadding = PaddingValues(vertical = 16.dp),
) { ) {
topics.forEach { followableTopic -> topics.forEach { followableTopic ->
val topicId = followableTopic.topic.id val topicId = followableTopic.topic.id

@ -2,7 +2,7 @@
accompanist = "0.28.0" accompanist = "0.28.0"
androidDesugarJdkLibs = "1.2.2" androidDesugarJdkLibs = "1.2.2"
androidGradlePlugin = "7.4.1" androidGradlePlugin = "7.4.1"
androidxActivity = "1.6.1" androidxActivity = "1.7.0"
androidxAppCompat = "1.5.1" androidxAppCompat = "1.5.1"
androidxBrowser = "1.4.0" androidxBrowser = "1.4.0"
androidxComposeBom = "2023.01.00" androidxComposeBom = "2023.01.00"
@ -87,26 +87,26 @@ androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", v
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" }
androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" }
androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" }
androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" }
androidx-tracing-ktx = { group = "androidx.tracing", name="tracing-ktx", version.ref = "androidxTracing" } androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" }
androidx-window-manager = { module = "androidx.window:window", version.ref = "androidxWindowManager" } androidx-window-manager = { module = "androidx.window:window", version.ref = "androidxWindowManager" }
androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" }
coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref="firebaseBom"} firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx"} firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx"} firebase-cloud-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
firebase-crashlytics-gradle = { group = "com.google.firebase", name="firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin"} firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" }
firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx"} firebase-crashlytics-gradle = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin" }
firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin"} firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" }
firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
@ -138,9 +138,9 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin"} firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }
firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin"} firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" }
gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin"} gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

@ -47,6 +47,7 @@ include(":core:network")
include(":core:ui") include(":core:ui")
include(":core:testing") include(":core:testing")
include(":core:analytics") include(":core:analytics")
include(":core:notifications")
include(":feature:foryou") include(":feature:foryou")
include(":feature:interests") include(":feature:interests")

@ -16,11 +16,12 @@
package com.google.samples.apps.nowinandroid.core.sync.test package com.google.samples.apps.nowinandroid.core.sync.test
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject import javax.inject.Inject
class NeverSyncingSyncStatusMonitor @Inject constructor() : SyncStatusMonitor { class NeverSyncingSyncManager @Inject constructor() : SyncManager {
override val isSyncing: Flow<Boolean> = flowOf(false) override val isSyncing: Flow<Boolean> = flowOf(false)
override fun requestSync() = Unit
} }

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.core.sync.test package com.google.samples.apps.nowinandroid.core.sync.test
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.sync.di.SyncModule import com.google.samples.apps.nowinandroid.sync.di.SyncModule
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@ -31,6 +31,6 @@ import dagger.hilt.testing.TestInstallIn
interface TestSyncModule { interface TestSyncModule {
@Binds @Binds
fun bindsSyncStatusMonitor( fun bindsSyncStatusMonitor(
syncStatusMonitor: NeverSyncingSyncStatusMonitor, syncStatusMonitor: NeverSyncingSyncManager,
): SyncStatusMonitor ): SyncManager
} }

@ -27,24 +27,22 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:analytics"))
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:analytics")) implementation(project(":core:model"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.startup)
implementation(libs.androidx.work.ktx) implementation(libs.androidx.work.ktx)
implementation(libs.firebase.cloud.messaging)
implementation(libs.hilt.ext.work) implementation(libs.hilt.ext.work)
implementation(libs.kotlinx.coroutines.android)
testImplementation(project(":core:testing"))
androidTestImplementation(project(":core:testing"))
kapt(libs.hilt.ext.compiler) kapt(libs.hilt.ext.compiler)
testImplementation(project(":core:testing"))
androidTestImplementation(project(":core:testing"))
androidTestImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.work.testing)
} }

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<service
android:name=".services.SyncNotificationsService"
android:exported="false"
tools:node="remove" />
</application>
</manifest>

@ -18,18 +18,13 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<application> <application>
<provider <service
android:name="androidx.startup.InitializationProvider" android:name=".services.SyncNotificationsService"
android:authorities="${applicationId}.androidx-startup" android:exported="false">
android:exported="false" <intent-filter>
tools:node="merge"> <action android:name="com.google.firebase.MESSAGING_EVENT" />
<!-- TODO: b/2173216 Disable auto sync startup till it works well with instrumented tests --> </intent-filter>
<meta-data </service>
android:name="com.google.samples.apps.nowinandroid.sync.initializers.SyncInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application> </application>
</manifest> </manifest>

@ -16,8 +16,8 @@
package com.google.samples.apps.nowinandroid.sync.di package com.google.samples.apps.nowinandroid.sync.di
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncStatusMonitor import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -28,6 +28,6 @@ import dagger.hilt.components.SingletonComponent
interface SyncModule { interface SyncModule {
@Binds @Binds
fun bindsSyncStatusMonitor( fun bindsSyncStatusMonitor(
syncStatusMonitor: WorkManagerSyncStatusMonitor, syncStatusMonitor: WorkManagerSyncManager,
): SyncStatusMonitor ): SyncManager
} }

@ -17,31 +17,14 @@
package com.google.samples.apps.nowinandroid.sync.initializers package com.google.samples.apps.nowinandroid.sync.initializers
import android.content.Context import android.content.Context
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkManagerInitializer
import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker
object Sync { object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on // This method is initializes sync, the process that keeps the app's data current.
// automatic initialization with Androidx Startup. It is called from the app module's // It is called from the app module's Application.onCreate() and should be only done once.
// Application.onCreate() and should be only done once.
fun initialize(context: Context) { fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}
// This name should not be changed otherwise the app may have concurrent sync requests running
internal const val SyncWorkName = "SyncWorkName"
/**
* Registers work to sync the data layer periodically on app startup.
*/
class SyncInitializer : Initializer<Sync> {
override fun create(context: Context): Sync {
WorkManager.getInstance(context).apply { WorkManager.getInstance(context).apply {
// Run sync on app startup and ensure only one sync worker runs at any time // Run sync on app startup and ensure only one sync worker runs at any time
enqueueUniqueWork( enqueueUniqueWork(
@ -50,10 +33,8 @@ class SyncInitializer : Initializer<Sync> {
SyncWorker.startUpSyncWork(), SyncWorker.startUpSyncWork(),
) )
} }
return Sync
} }
override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(WorkManagerInitializer::class.java)
} }
// This name should not be changed otherwise the app may have concurrent sync requests running
internal const val SyncWorkName = "SyncWorkName"

@ -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.sync.services
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
private const val SYNC_TOPIC = "sync"
@AndroidEntryPoint
class SyncNotificationsService : FirebaseMessagingService() {
@Inject
lateinit var syncManager: SyncManager
override fun onMessageReceived(message: RemoteMessage) {
if (SYNC_TOPIC == message.from) {
syncManager.requestSync()
}
}
}

@ -19,27 +19,39 @@ package com.google.samples.apps.nowinandroid.sync.status
import android.content.Context import android.content.Context
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo import androidx.work.WorkInfo
import androidx.work.WorkInfo.State import androidx.work.WorkInfo.State
import androidx.work.WorkManager import androidx.work.WorkManager
import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName
import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import javax.inject.Inject import javax.inject.Inject
/** /**
* [SyncStatusMonitor] backed by [WorkInfo] from [WorkManager] * [SyncManager] backed by [WorkInfo] from [WorkManager]
*/ */
class WorkManagerSyncStatusMonitor @Inject constructor( class WorkManagerSyncManager @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext private val context: Context,
) : SyncStatusMonitor { ) : SyncManager {
override val isSyncing: Flow<Boolean> = override val isSyncing: Flow<Boolean> =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName) WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName)
.map(MutableList<WorkInfo>::anyRunning) .map(MutableList<WorkInfo>::anyRunning)
.asFlow() .asFlow()
.conflate() .conflate()
override fun requestSync() {
val workManager = WorkManager.getInstance(context)
// Run sync on app startup and ensure only one sync worker runs at any time
workManager.enqueueUniqueWork(
SyncWorkName,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork(),
)
}
} }
private val List<WorkInfo>.anyRunning get() = any { it.state == State.RUNNING } private val List<WorkInfo>.anyRunning get() = any { it.state == State.RUNNING }
Loading…
Cancel
Save