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 1 year ago
commit d02ca372ea

@ -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,11 @@ 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,
)
}
} }
}, },
) )

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

@ -14,4 +14,6 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. 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. * 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>)
} }

@ -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. limitations under the License.
--> -->
<resources> <resources>
<string name="sync_notification_title">Now in Android</string> <string name="news_notification_title">Now in Android</string>
<string name="sync_notification_channel_name">Sync</string> <string name="news_notification_channel_name">News updates</string>
<string name="sync_notification_channel_description">Background tasks for Now in Android</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> </resources>

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

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

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

@ -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,9 @@
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_CODES
import androidx.activity.compose.ReportDrawnWhen 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
@ -57,10 +60,13 @@ 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.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
@ -73,6 +79,9 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace import androidx.compose.ui.util.trace
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.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.DynamicAsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton 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.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
@ -96,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,
@ -115,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,
@ -199,6 +214,11 @@ internal fun ForYouScreen(
} }
} }
TrackScreenViewEvent(screenName = "ForYou") 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 @DevicePreviews
@Composable @Composable
fun ForYouScreenPopulatedFeed( fun ForYouScreenPopulatedFeed(
@ -397,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 = {},
) )
} }
} }
@ -421,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 = {},
) )
} }
} }
@ -448,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 = {},
) )
} }
} }
@ -467,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 = {},
) )
} }
} }
@ -491,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 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) { 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(

@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells.Adaptive 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items 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.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.R.string 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.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.core.ui.newsFeed
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksViewModel 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.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.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent
import com.google.samples.apps.nowinandroid.feature.search.R as searchR import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@Composable @Composable
@ -289,49 +289,72 @@ private fun SearchResultBody(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
searchQuery: String = "", searchQuery: String = "",
) { ) {
if (topics.isNotEmpty()) { val state = rememberLazyGridState()
Text( LazyVerticalGrid(
text = buildAnnotatedString { columns = Adaptive(300.dp),
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { contentPadding = PaddingValues(16.dp),
append(stringResource(id = searchR.string.topics)) 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()) { if (newsResources.isNotEmpty()) {
Text( item(
text = buildAnnotatedString { span = {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { GridItemSpan(maxLineSpan)
append(stringResource(id = searchR.string.updates)) },
} ) {
}, Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), 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( newsFeed(
feedState = NewsFeedUiState.Success(feed = newsResources), feedState = NewsFeedUiState.Success(feed = newsResources),
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,

@ -55,6 +55,7 @@ turbine = "0.12.1"
[libraries] [libraries]
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } 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-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" } 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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }

Loading…
Cancel
Save