Merge remote-tracking branch 'github/main' into may23automerger

* github/main:
  Improvement in the Search screen (#717)
  Fix build
  Fix navigation tests
  Route notification deep link through for you screen, with spotless fixes
  Route notification deep link through for you screen
  Add pending intent for opening notification links
  Notify users when news are updated

Change-Id: Id0373d61be19ab6fa9c42f6beca28a42dbdde225
pull/836/head
Don Turner 2 years ago
commit d02ca372ea

@ -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(

@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
@ -83,12 +84,11 @@ import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.R.string
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.newsFeed
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksViewModel
import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel
import com.google.samples.apps.nowinandroid.feature.interests.InterestsItem
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent
import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@Composable
@ -289,49 +289,72 @@ private fun SearchResultBody(
onTopicClick: (String) -> Unit,
searchQuery: String = "",
) {
if (topics.isNotEmpty()) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.topics))
val state = rememberLazyGridState()
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxSize()
.testTag("search:newsResources"),
state = state,
) {
if (topics.isNotEmpty()) {
item(
span = {
GridItemSpan(maxLineSpan)
},
) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.topics))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
topics.forEach { followableTopic ->
val topicId = followableTopic.topic.id
item(
key = "topic-$topicId", // Append a prefix to distinguish a key for news resources
span = {
GridItemSpan(maxLineSpan)
},
) {
InterestsItem(
name = followableTopic.topic.name,
following = followableTopic.isFollowed,
description = followableTopic.topic.shortDescription,
topicImageUrl = followableTopic.topic.imageUrl,
onClick = {
// Pass the current search query to ViewModel to save it as recent searches
onSearchTriggered(searchQuery)
onTopicClick(topicId)
},
onFollowButtonClick = { onFollowButtonClick(topicId, it) },
)
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
TopicsTabContent(
topics = topics,
onTopicClick = {
// Pass the current search query to ViewModel to save it as recent searches
onSearchTriggered(searchQuery)
onTopicClick(it)
},
onFollowButtonClick = onFollowButtonClick,
withBottomSpacer = false,
)
}
}
}
if (newsResources.isNotEmpty()) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.updates))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (newsResources.isNotEmpty()) {
item(
span = {
GridItemSpan(maxLineSpan)
},
) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(id = searchR.string.updates))
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
val state = rememberLazyGridState()
TrackScrollJank(scrollableState = state, stateName = "search:newsResource")
LazyVerticalGrid(
columns = Adaptive(300.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxSize()
.testTag("search:newsResources"),
state = state,
) {
newsFeed(
feedState = NewsFeedUiState.Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,

@ -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