Merge pull request #712 from android/tj/news-notifications

Notify users when news are updated
pull/691/merge
Adetunji Dahunsi 2 years ago committed by GitHub
commit 3b1b1ea4c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -42,9 +42,13 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<data
android:scheme="https"
android:host="www.nowinandroid.apps.samples.google.com" />
</intent-filter>
</activity>
<!-- Disable Firebase analytics by default. This setting is overwritten for the `prod`

@ -129,7 +129,11 @@ class OfflineFirstNewsRepository @Inject constructor(
.first()
.map(PopulatedNewsResource::asExternalModel)
if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources)
if (addedNewsResources.isNotEmpty()) {
notifier.postNewsNotifications(
newsResources = addedNewsResources,
)
}
}
},
)

@ -24,9 +24,11 @@ android {
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:model"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.browser)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)

@ -14,4 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"/>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
</manifest>

@ -1,32 +0,0 @@
/*
* 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
}
}

@ -23,5 +23,5 @@ 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
override fun postNewsNotifications(newsResources: List<NewsResource>) = Unit
}

@ -22,5 +22,5 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
* Interface for creating notifications in the app
*/
interface Notifier {
fun onNewsAdded(newsResources: List<NewsResource>)
fun postNewsNotifications(newsResources: List<NewsResource>)
}

@ -0,0 +1,176 @@
/*
* 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 android.Manifest.permission
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.InboxStyle
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
private const val MAX_NUM_NOTIFICATIONS = 5
private const val TARGET_ACTIVITY_NAME = "com.google.samples.apps.nowinandroid.MainActivity"
private const val NEWS_NOTIFICATION_REQUEST_CODE = 0
private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS"
private const val DEEP_LINK_SCHEME_AND_HOST = "https://www.nowinandroid.apps.samples.google.com"
private const val FOR_YOU_PATH = "foryou"
/**
* Implementation of [Notifier] that displays notifications in the system tray.
*/
@Singleton
class SystemTrayNotifier @Inject constructor(
@ApplicationContext private val context: Context,
) : Notifier {
override fun postNewsNotifications(
newsResources: List<NewsResource>,
) = with(context) {
if (ActivityCompat.checkSelfPermission(
this,
permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
val truncatedNewsResources = newsResources
.take(MAX_NUM_NOTIFICATIONS)
val newsNotifications = truncatedNewsResources
.map { newsResource ->
createNewsNotification {
setSmallIcon(
com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification,
)
.setContentTitle(newsResource.title)
.setContentText(newsResource.content)
.setContentIntent(newsPendingIntent(newsResource))
.setGroup(NEWS_NOTIFICATION_GROUP)
.setAutoCancel(true)
}
}
val summaryNotification = createNewsNotification {
val title = getString(
R.string.news_notification_group_summary,
truncatedNewsResources.size,
)
setContentTitle(title)
.setContentText(title)
.setSmallIcon(
com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification,
)
// Build summary info into InboxStyle template.
.setStyle(newsNotificationStyle(truncatedNewsResources, title))
.setGroup(NEWS_NOTIFICATION_GROUP)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
}
// Send the notifications
val notificationManager = NotificationManagerCompat.from(this)
newsNotifications.forEachIndexed { index, notification ->
notificationManager.notify(
truncatedNewsResources[index].id.hashCode(),
notification,
)
}
notificationManager.notify(NEWS_NOTIFICATION_SUMMARY_ID, summaryNotification)
}
/**
* Creates an inbox style summary notification for news updates
*/
private fun newsNotificationStyle(
newsResources: List<NewsResource>,
title: String,
): InboxStyle = newsResources
.fold(InboxStyle()) { inboxStyle, newsResource ->
inboxStyle.addLine(newsResource.title)
}
.setBigContentTitle(title)
.setSummaryText(title)
}
/**
* Creates a notification for configured for news updates
*/
private fun Context.createNewsNotification(
block: NotificationCompat.Builder.() -> Unit,
): Notification {
ensureNotificationChannelExists()
return NotificationCompat.Builder(
this,
NEWS_NOTIFICATION_CHANNEL_ID,
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.apply(block)
.build()
}
/**
* Ensures the a notification channel is is present if applicable
*/
private fun Context.ensureNotificationChannelExists() {
if (VERSION.SDK_INT < VERSION_CODES.O) return
val channel = NotificationChannel(
NEWS_NOTIFICATION_CHANNEL_ID,
getString(R.string.news_notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = getString(R.string.news_notification_channel_description)
}
// Register the channel with the system
NotificationManagerCompat.from(this).createNotificationChannel(channel)
}
private fun Context.newsPendingIntent(
newsResource: NewsResource,
): PendingIntent? = PendingIntent.getActivity(
this,
NEWS_NOTIFICATION_REQUEST_CODE,
Intent().apply {
action = Intent.ACTION_VIEW
data = newsResource.newsDeepLinkUri()
component = ComponentName(
packageName,
TARGET_ACTIVITY_NAME,
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_SCHEME_AND_HOST/$FOR_YOU_PATH/$id".toUri()

@ -15,8 +15,8 @@
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>
<string name="news_notification_title">Now in Android</string>
<string name="news_notification_channel_name">News updates</string>
<string name="news_notification_channel_description">The latest updates on what\'s new in Android</string>
<string name="news_notification_group_summary">%1$d news updates</string>
</resources>

@ -26,6 +26,6 @@ import dagger.hilt.components.SingletonComponent
abstract class NotificationsModule {
@Binds
abstract fun bindNotifier(
notifier: AndroidSystemNotifier,
notifier: SystemTrayNotifier,
): Notifier
}

@ -28,7 +28,7 @@ class TestNotifier : Notifier {
val addedNewsResources: List<List<NewsResource>> = mutableAddedNewResources
override fun onNewsAdded(newsResources: List<NewsResource>) {
override fun postNewsNotifications(newsResources: List<NewsResource>) {
mutableAddedNewResources.add(newsResources)
}
}

@ -29,4 +29,5 @@ android {
dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.permissions)
}

@ -52,11 +52,13 @@ class ForYouScreenTest {
isSyncing = false,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}
}
@ -76,11 +78,13 @@ class ForYouScreenTest {
isSyncing = true,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Success(emptyList()),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}
}
@ -106,11 +110,13 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success(
feed = emptyList(),
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}
}
@ -151,11 +157,13 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success(
feed = emptyList(),
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}
}
@ -189,11 +197,13 @@ class ForYouScreenTest {
onboardingUiState =
OnboardingUiState.Shown(topics = followableTopicTestData),
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}
}
@ -213,11 +223,13 @@ class ForYouScreenTest {
isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown,
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}
}
@ -238,11 +250,13 @@ class ForYouScreenTest {
feedState = NewsFeedUiState.Success(
feed = userNewsResourcesTestData,
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
onTopicClick = {},
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onDeepLinkOpened = {},
)
}

@ -16,6 +16,9 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import android.net.Uri
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -57,10 +60,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@ -73,6 +79,9 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied
import com.google.accompanist.permissions.rememberPermissionState
import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
@ -85,6 +94,7 @@ import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable
@ -96,12 +106,15 @@ internal fun ForYouRoute(
val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val deepLinkedUserNewsResource by viewModel.deepLinkedNewsResource.collectAsStateWithLifecycle()
ForYouScreen(
isSyncing = isSyncing,
onboardingUiState = onboardingUiState,
feedState = feedState,
deepLinkedUserNewsResource = deepLinkedUserNewsResource,
onTopicCheckedChanged = viewModel::updateTopicSelection,
onDeepLinkOpened = viewModel::onDeepLinkOpened,
onTopicClick = onTopicClick,
saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
@ -115,8 +128,10 @@ internal fun ForYouScreen(
isSyncing: Boolean,
onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState,
deepLinkedUserNewsResource: UserNewsResource?,
onTopicCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit,
onDeepLinkOpened: (String) -> Unit,
saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
@ -199,6 +214,11 @@ internal fun ForYouScreen(
}
}
TrackScreenViewEvent(screenName = "ForYou")
NotificationPermissionEffect()
DeepLinkEffect(
deepLinkedUserNewsResource,
onDeepLinkOpened,
)
}
/**
@ -383,6 +403,41 @@ fun TopicIcon(
)
}
@Composable
@OptIn(ExperimentalPermissionsApi::class)
private fun NotificationPermissionEffect() {
if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return
val notificationsPermissionState = rememberPermissionState(
android.Manifest.permission.POST_NOTIFICATIONS,
)
LaunchedEffect(notificationsPermissionState) {
val status = notificationsPermissionState.status
if (status is Denied && !status.shouldShowRationale) {
notificationsPermissionState.launchPermissionRequest()
}
}
}
@Composable
private fun DeepLinkEffect(
userNewsResource: UserNewsResource?,
onDeepLinkOpened: (String) -> Unit,
) {
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
LaunchedEffect(userNewsResource) {
if (userNewsResource == null) return@LaunchedEffect
if (!userNewsResource.hasBeenViewed) onDeepLinkOpened(userNewsResource.id)
launchCustomChromeTab(
context = context,
uri = Uri.parse(userNewsResource.url),
toolbarColor = backgroundColor,
)
}
}
@DevicePreviews
@Composable
fun ForYouScreenPopulatedFeed(
@ -397,11 +452,13 @@ fun ForYouScreenPopulatedFeed(
feedState = NewsFeedUiState.Success(
feed = userNewsResources,
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {},
onDeepLinkOpened = {},
)
}
}
@ -421,11 +478,13 @@ fun ForYouScreenOfflinePopulatedFeed(
feedState = NewsFeedUiState.Success(
feed = userNewsResources,
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {},
onDeepLinkOpened = {},
)
}
}
@ -448,11 +507,13 @@ fun ForYouScreenTopicSelection(
feedState = NewsFeedUiState.Success(
feed = userNewsResources,
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {},
onDeepLinkOpened = {},
)
}
}
@ -467,11 +528,13 @@ fun ForYouScreenLoading() {
isSyncing = false,
onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {},
onDeepLinkOpened = {},
)
}
}
@ -491,11 +554,13 @@ fun ForYouScreenPopulatedAndLoading(
feedState = NewsFeedUiState.Success(
feed = userNewsResources,
),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {},
onTopicClick = {},
onDeepLinkOpened = {},
)
}
}

@ -16,18 +16,23 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
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.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -35,6 +40,7 @@ import javax.inject.Inject
@HiltViewModel
class ForYouViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
syncManager: SyncManager,
private val userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository,
@ -44,6 +50,28 @@ class ForYouViewModel @Inject constructor(
private val shouldShowOnboarding: Flow<Boolean> =
userDataRepository.userData.map { !it.shouldHideOnboarding }
val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>(
key = LINKED_NEWS_RESOURCE_ID,
null,
)
.flatMapLatest { newsResourceId ->
if (newsResourceId == null) {
flowOf(emptyList())
} else {
userNewsResourceRepository.observeAll(
NewsResourceQuery(
filterNewsIds = setOf(newsResourceId),
),
)
}
}
.map { it.firstOrNull() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
val isSyncing = syncManager.isSyncing
.stateIn(
scope = viewModelScope,
@ -95,6 +123,18 @@ class ForYouViewModel @Inject constructor(
}
}
fun onDeepLinkOpened(newsResourceId: String) {
if (newsResourceId == deepLinkedNewsResource.value?.id) {
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = null
}
viewModelScope.launch {
userDataRepository.setNewsResourceViewed(
newsResourceId = newsResourceId,
viewed = true,
)
}
}
fun dismissOnboarding() {
viewModelScope.launch {
userDataRepository.setShouldHideOnboarding(true)

@ -19,17 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
const val forYouNavigationRoute = "for_you_route"
const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId"
const val forYouNavigationRoute = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}"
private const val DEEP_LINK_URI_PATTERN =
"https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}"
fun NavController.navigateToForYou(navOptions: NavOptions? = null) {
this.navigate(forYouNavigationRoute, navOptions)
}
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable(route = forYouNavigationRoute) {
composable(
route = forYouNavigationRoute,
deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
),
arguments = listOf(
navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType },
),
) {
ForYouRoute(onTopicClick)
}
}

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.SavedStateHandle
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@ -32,6 +33,7 @@ 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.TestSyncManager
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -42,6 +44,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* To learn more about how this test handles Flows created with stateIn, see
@ -65,12 +68,14 @@ class ForYouViewModelTest {
topicsRepository = topicsRepository,
userDataRepository = userDataRepository,
)
private val savedStateHandle = SavedStateHandle()
private lateinit var viewModel: ForYouViewModel
@Before
fun setup() {
viewModel = ForYouViewModel(
syncManager = syncManager,
savedStateHandle = savedStateHandle,
userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository,
getFollowableTopics = getFollowableTopicsUseCase,
@ -455,6 +460,34 @@ class ForYouViewModelTest {
collectJob1.cancel()
collectJob2.cancel()
}
@Test
fun deepLinkedNewsResourceIsFetchedAndResetAfterViewing() = runTest {
val collectJob =
launch(UnconfinedTestDispatcher()) { viewModel.deepLinkedNewsResource.collect() }
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData)
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = sampleNewsResources.first().id
assertEquals(
expected = UserNewsResource(
newsResource = sampleNewsResources.first(),
userData = emptyUserData,
),
actual = viewModel.deepLinkedNewsResource.value,
)
viewModel.onDeepLinkOpened(
newsResourceId = sampleNewsResources.first().id,
)
assertNull(
viewModel.deepLinkedNewsResource.value,
)
collectJob.cancel()
}
}
private val sampleTopics = listOf(

@ -55,6 +55,7 @@ turbine = "0.12.1"
[libraries]
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" }
accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version.ref = "accompanist" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }

Loading…
Cancel
Save