Route notification deep link through for you screen

pull/712/head
TJ Dahunsi 2 years ago
parent 8a3a16de21
commit ef97cb941c

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

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

@ -23,5 +23,5 @@ import javax.inject.Inject
* Implementation of [Notifier] which does nothing. Useful for tests and previews. * Implementation of [Notifier] which does nothing. Useful for tests and previews.
*/ */
class NoOpNotifier @Inject constructor() : Notifier { 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 for creating notifications in the app
*/ */
interface Notifier { interface Notifier {
fun onNewsAdded(newsResources: List<NewsResource>) fun postNewsNotifications(newsResources: List<NewsResource>)
} }

@ -21,60 +21,70 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.EXTRA_NOTIFICATION_ID
import androidx.core.app.NotificationCompat.InboxStyle import androidx.core.app.NotificationCompat.InboxStyle
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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_REQUEST_CODE = 0
private const val NEWS_NOTIFICATION_SUMMARY_ID = 1 private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = "" private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS" 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. * Implementation of [Notifier] that displays notifications in the system tray.
*/ */
@Singleton @Singleton
class AndroidSystemNotifier @Inject constructor( class SystemTrayNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : Notifier { ) : Notifier {
override fun onNewsAdded( override fun postNewsNotifications(
newsResources: List<NewsResource>, newsResources: List<NewsResource>,
) = with(context) { ) = with(context) {
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
this, this,
permission.POST_NOTIFICATIONS, permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED ) != PackageManager.PERMISSION_GRANTED
) return ) {
return
}
val newsNotifications = newsResources.map { newsResource -> val truncatedNewsResources = newsResources
newsNotification { .take(MAX_NUM_NOTIFICATIONS)
setSmallIcon(
com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, val newsNotifications = truncatedNewsResources
) .map { newsResource ->
.setContentTitle(newsResource.title) createNewsNotification {
.setContentText(newsResource.content) setSmallIcon(
.setContentIntent(newsPendingIntent(newsResource)) com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification,
.setGroup(NEWS_NOTIFICATION_GROUP) )
.setAutoCancel(true) .setContentTitle(newsResource.title)
.setContentText(newsResource.content)
.setContentIntent(newsPendingIntent(newsResource))
.setGroup(NEWS_NOTIFICATION_GROUP)
.setAutoCancel(true)
}
} }
} val summaryNotification = createNewsNotification {
val summaryNotification = newsNotification {
val title = getString( val title = getString(
R.string.news_notification_group_summary, R.string.news_notification_group_summary,
newsNotifications.size, truncatedNewsResources.size,
) )
setContentTitle(title) setContentTitle(title)
.setContentText(title) .setContentText(title)
@ -82,30 +92,31 @@ class AndroidSystemNotifier @Inject constructor(
com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification,
) )
// Build summary info into InboxStyle template. // Build summary info into InboxStyle template.
.setStyle(newsInboxStyle(newsResources, title)) .setStyle(newsNotificationStyle(truncatedNewsResources, title))
.setGroup(NEWS_NOTIFICATION_GROUP) .setGroup(NEWS_NOTIFICATION_GROUP)
.setGroupSummary(true) .setGroupSummary(true)
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
} }
with(NotificationManagerCompat.from(this)) { // Send the notifications
newsNotifications.forEachIndexed { index, notification -> val notificationManager = NotificationManagerCompat.from(this)
notify(newsResources[index].id.hashCode(), notification) newsNotifications.forEachIndexed { index, notification ->
} notificationManager.notify(
notify(NEWS_NOTIFICATION_SUMMARY_ID, summaryNotification) truncatedNewsResources[index].id.hashCode(),
notification,
)
} }
notificationManager.notify(NEWS_NOTIFICATION_SUMMARY_ID, summaryNotification)
} }
/** /**
* Creates an inbox style summary notification for news updates * Creates an inbox style summary notification for news updates
*/ */
private fun newsInboxStyle( private fun newsNotificationStyle(
newsResources: List<NewsResource>, newsResources: List<NewsResource>,
title: String, title: String,
): InboxStyle = newsResources ): InboxStyle = newsResources
// Show at most 5 lines
.take(5)
.fold(InboxStyle()) { inboxStyle, newsResource -> .fold(InboxStyle()) { inboxStyle, newsResource ->
inboxStyle.addLine(newsResource.title) inboxStyle.addLine(newsResource.title)
} }
@ -116,10 +127,10 @@ class AndroidSystemNotifier @Inject constructor(
/** /**
* Creates a notification for configured for news updates * Creates a notification for configured for news updates
*/ */
private fun Context.newsNotification( private fun Context.createNewsNotification(
block: NotificationCompat.Builder.() -> Unit, block: NotificationCompat.Builder.() -> Unit,
): Notification { ): Notification {
ensureNotificationChannel() ensureNotificationChannelExists()
return NotificationCompat.Builder( return NotificationCompat.Builder(
this, this,
NEWS_NOTIFICATION_CHANNEL_ID, NEWS_NOTIFICATION_CHANNEL_ID,
@ -132,7 +143,7 @@ private fun Context.newsNotification(
/** /**
* Ensures the a notification channel is is present if applicable * Ensures the a notification channel is is present if applicable
*/ */
private fun Context.ensureNotificationChannel() { private fun Context.ensureNotificationChannelExists() {
if (VERSION.SDK_INT < VERSION_CODES.O) return if (VERSION.SDK_INT < VERSION_CODES.O) return
val channel = NotificationChannel( val channel = NotificationChannel(
@ -146,22 +157,20 @@ private fun Context.ensureNotificationChannel() {
NotificationManagerCompat.from(this).createNotificationChannel(channel) NotificationManagerCompat.from(this).createNotificationChannel(channel)
} }
private fun Context.newsPendingIntent(newsResource: NewsResource): PendingIntent? = private fun Context.newsPendingIntent(
PendingIntent.getActivity( newsResource: NewsResource,
this, ): PendingIntent? = PendingIntent.getActivity(
NEWS_NOTIFICATION_REQUEST_CODE, this,
// TODO: Read color from material theme to style the chrome custom tab NEWS_NOTIFICATION_REQUEST_CODE,
// this is currently only readable from composition. Intent().apply {
CustomTabsIntent.Builder() action = Intent.ACTION_VIEW
.build() data = newsResource.newsDeepLinkUri()
.apply { component = ComponentName(
intent.data = Uri.parse(newsResource.url) packageName,
if (VERSION.SDK_INT < VERSION_CODES.O) { TARGET_ACTIVITY_NAME,
intent.putExtra( )
EXTRA_NOTIFICATION_ID, },
newsResource.id.hashCode(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
}
}.intent, private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_SCHEME_AND_HOST/$FOR_YOU_PATH/$id".toUri()
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

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

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

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import android.net.Uri
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.activity.compose.ReportDrawnWhen import androidx.activity.compose.ReportDrawnWhen
@ -63,7 +64,9 @@ 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.graphics.toArgb
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -91,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.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank 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.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable @Composable
@ -102,12 +106,15 @@ internal fun ForYouRoute(
val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle() val onboardingUiState by viewModel.onboardingUiState.collectAsStateWithLifecycle()
val feedState by viewModel.feedState.collectAsStateWithLifecycle() val feedState by viewModel.feedState.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val deepLinkedUserNewsResource by viewModel.deepLinkedNewsResource.collectAsStateWithLifecycle()
ForYouScreen( ForYouScreen(
isSyncing = isSyncing, isSyncing = isSyncing,
onboardingUiState = onboardingUiState, onboardingUiState = onboardingUiState,
feedState = feedState, feedState = feedState,
deepLinkedUserNewsResource = deepLinkedUserNewsResource,
onTopicCheckedChanged = viewModel::updateTopicSelection, onTopicCheckedChanged = viewModel::updateTopicSelection,
onDeepLinkOpened = viewModel::onDeepLinkOpened,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
saveFollowedTopics = viewModel::dismissOnboarding, saveFollowedTopics = viewModel::dismissOnboarding,
onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved,
@ -121,8 +128,10 @@ internal fun ForYouScreen(
isSyncing: Boolean, isSyncing: Boolean,
onboardingUiState: OnboardingUiState, onboardingUiState: OnboardingUiState,
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
deepLinkedUserNewsResource: UserNewsResource?,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onDeepLinkOpened: (String) -> Unit,
saveFollowedTopics: () -> Unit, saveFollowedTopics: () -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit, onNewsResourceViewed: (String) -> Unit,
@ -205,7 +214,11 @@ internal fun ForYouScreen(
} }
} }
TrackScreenViewEvent(screenName = "ForYou") TrackScreenViewEvent(screenName = "ForYou")
requestNotificationsPermission() NotificationPermissionEffect()
DeepLinkEffect(
deepLinkedUserNewsResource,
onDeepLinkOpened,
)
} }
/** /**
@ -392,7 +405,7 @@ fun TopicIcon(
@Composable @Composable
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
private fun requestNotificationsPermission() { private fun NotificationPermissionEffect() {
if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return
val notificationsPermissionState = rememberPermissionState( val notificationsPermissionState = rememberPermissionState(
android.Manifest.permission.POST_NOTIFICATIONS, android.Manifest.permission.POST_NOTIFICATIONS,
@ -405,6 +418,26 @@ private fun requestNotificationsPermission() {
} }
} }
@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 @DevicePreviews
@Composable @Composable
fun ForYouScreenPopulatedFeed( fun ForYouScreenPopulatedFeed(
@ -419,11 +452,13 @@ fun ForYouScreenPopulatedFeed(
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = userNewsResources, feed = userNewsResources,
), ),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
onDeepLinkOpened = {},
) )
} }
} }
@ -443,11 +478,13 @@ fun ForYouScreenOfflinePopulatedFeed(
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = userNewsResources, feed = userNewsResources,
), ),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
onDeepLinkOpened = {},
) )
} }
} }
@ -470,11 +507,13 @@ fun ForYouScreenTopicSelection(
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = userNewsResources, feed = userNewsResources,
), ),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
onDeepLinkOpened = {},
) )
} }
} }
@ -489,11 +528,13 @@ fun ForYouScreenLoading() {
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
onDeepLinkOpened = {},
) )
} }
} }
@ -513,11 +554,13 @@ fun ForYouScreenPopulatedAndLoading(
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
feed = userNewsResources, feed = userNewsResources,
), ),
deepLinkedUserNewsResource = null,
onTopicCheckedChanged = { _, _ -> }, onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}, saveFollowedTopics = {},
onNewsResourcesCheckedChanged = { _, _ -> }, onNewsResourcesCheckedChanged = { _, _ -> },
onNewsResourceViewed = {}, onNewsResourceViewed = {},
onTopicClick = {}, onTopicClick = {},
onDeepLinkOpened = {},
) )
} }
} }

@ -16,18 +16,23 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.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.SyncManager 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 com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -35,6 +40,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
syncManager: SyncManager, syncManager: SyncManager,
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
@ -44,6 +50,28 @@ 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 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 val isSyncing = syncManager.isSyncing
.stateIn( .stateIn(
scope = viewModelScope, 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() { fun dismissOnboarding() {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.setShouldHideOnboarding(true) userDataRepository.setShouldHideOnboarding(true)

@ -19,17 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId"
const val forYouNavigationRoute = "for_you_route" const val forYouNavigationRoute = "for_you_route"
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) { fun NavController.navigateToForYou(navOptions: NavOptions? = null) {
this.navigate(forYouNavigationRoute, navOptions) this.navigate(forYouNavigationRoute, navOptions)
} }
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) { 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) ForYouRoute(onTopicClick)
} }
} }

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou 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.data.repository.CompositeUserNewsResourceRepository
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.model.data.FollowableTopic 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.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager 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 com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -42,6 +44,7 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see
@ -65,12 +68,14 @@ class ForYouViewModelTest {
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
) )
private val savedStateHandle = SavedStateHandle()
private lateinit var viewModel: ForYouViewModel private lateinit var viewModel: ForYouViewModel
@Before @Before
fun setup() { fun setup() {
viewModel = ForYouViewModel( viewModel = ForYouViewModel(
syncManager = syncManager, syncManager = syncManager,
savedStateHandle = savedStateHandle,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
@ -455,6 +460,34 @@ class ForYouViewModelTest {
collectJob1.cancel() collectJob1.cancel()
collectJob2.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( private val sampleTopics = listOf(

Loading…
Cancel
Save