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" }