diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 99c233910..0b0482c13 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,9 +42,13 @@
android:exported="true">
-
+
+
+
-
+
+
+
diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt
deleted file mode 100644
index 00d97fcb3..000000000
--- a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt
+++ /dev/null
@@ -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) {
- // TODO, create notification and display to the user
- }
-}
diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt
index 5a8141e91..d17005bca 100644
--- a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt
+++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt
@@ -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) = Unit
+ override fun postNewsNotifications(newsResources: List) = Unit
}
diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt
index 3084dcb75..fff8cb9c8 100644
--- a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt
+++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt
@@ -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)
+ fun postNewsNotifications(newsResources: List)
}
diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
new file mode 100644
index 000000000..b7fcc9b26
--- /dev/null
+++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
@@ -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,
+ ) = 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,
+ 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()
diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml
index e3fd73ff8..a3f8a4e61 100644
--- a/core/notifications/src/main/res/values/strings.xml
+++ b/core/notifications/src/main/res/values/strings.xml
@@ -15,8 +15,8 @@
limitations under the License.
-->
- Now in Android
- Sync
- Background tasks for Now in Android
-
+ Now in Android
+ News updates
+ The latest updates on what\'s new in Android
+ %1$d news updates
diff --git a/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt
index 0b4bd6bae..3c05e9c6e 100644
--- a/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt
+++ b/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt
@@ -26,6 +26,6 @@ import dagger.hilt.components.SingletonComponent
abstract class NotificationsModule {
@Binds
abstract fun bindNotifier(
- notifier: AndroidSystemNotifier,
+ notifier: SystemTrayNotifier,
): Notifier
}
diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt
index 669d2e6c4..d57c4ed75 100644
--- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt
+++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt
@@ -28,7 +28,7 @@ class TestNotifier : Notifier {
val addedNewsResources: List> = mutableAddedNewResources
- override fun onNewsAdded(newsResources: List) {
+ override fun postNewsNotifications(newsResources: List) {
mutableAddedNewResources.add(newsResources)
}
}
diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts
index 8c6747dd1..6cd5216d6 100644
--- a/feature/foryou/build.gradle.kts
+++ b/feature/foryou/build.gradle.kts
@@ -29,4 +29,5 @@ android {
dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.activity.compose)
+ implementation(libs.accompanist.permissions)
}
diff --git a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt
index fde215aa1..eb27473bb 100644
--- a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt
+++ b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt
@@ -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 = {},
)
}
diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
index 06c73c971..ebc0a6fe9 100644
--- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
+++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
@@ -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 = {},
)
}
}
diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
index 18d24118b..0e910ea06 100644
--- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
+++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
@@ -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 =
userDataRepository.userData.map { !it.shouldHideOnboarding }
+ val deepLinkedNewsResource = savedStateHandle.getStateFlow(
+ 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)
diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
index c7dea1e96..705495cc2 100644
--- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
+++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
@@ -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)
}
}
diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
index 62993dc9f..e99cfb74d 100644
--- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
+++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
@@ -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(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ccfdeca99..d3af7678f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }