Merge branch 'main' into patch-2

pull/743/head
Simon Marquis 1 year ago
commit 61098d75b2
No known key found for this signature in database
GPG Key ID: AC8D63F7571DC6D6

@ -1,49 +0,0 @@
name: Android CI with GMD
on:
push:
branches:
- main
pull_request:
jobs:
android-ci:
runs-on: macos-12
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Accept Android licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
- name: Build AndroidTest apps
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest
- name: Run instrumented tests with GMD
run: ./gradlew cleanManagedDevices --unused-only &&
./gradlew ciDemoDebugAndroidTest -Dorg.gradle.workers.max=1
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
- name: Upload test reports
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: '**/build/reports/androidTests'

@ -5,6 +5,7 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
concurrency: concurrency:
group: build-${{ github.ref }} group: build-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@ -100,7 +101,7 @@ jobs:
disable-animations: true disable-animations: true
disk-size: 6000M disk-size: 6000M
heap-size: 600M heap-size: 600M
script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon script: ./gradlew connectedDemoDebugAndroidTest --daemon
- name: Upload test reports - name: Upload test reports
if: always() if: always()
@ -108,3 +109,42 @@ jobs:
with: with:
name: test-reports-${{ matrix.api-level }} name: test-reports-${{ matrix.api-level }}
path: '**/build/reports/androidTests' path: '**/build/reports/androidTests'
androidTest-GMD:
needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 55
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Accept Android licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
- name: Build AndroidTest apps
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest
- name: Run instrumented tests with GMD
run: ./gradlew cleanManagedDevices --unused-only &&
./gradlew ciDemoDebugAndroidTest -Dorg.gradle.workers.max=1
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
- name: Upload test reports
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: '**/build/reports/androidTests'

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -132,7 +132,14 @@ The app uses adaptive layouts to
Find out more about the [UI architecture here](docs/ArchitectureLearningJourney.md#ui-layer). Find out more about the [UI architecture here](docs/ArchitectureLearningJourney.md#ui-layer).
# Baseline profiles # Performance
## Benchmarks
Find all tests written using [`Macrobenchmark`](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview)
in the `benchmarks` module. This module also contains the test to generate the Baseline profile.
## Baseline profiles
The baseline profile for this app is located at [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt). The baseline profile for this app is located at [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt).
It contains rules that enable AOT compilation of the critical user path taken during app launch. It contains rules that enable AOT compilation of the critical user path taken during app launch.
@ -144,6 +151,19 @@ To generate the baseline profile, select the `benchmark` build variant and run t
`BaselineProfileGenerator` benchmark test on an AOSP Android Emulator. `BaselineProfileGenerator` benchmark test on an AOSP Android Emulator.
Then copy the resulting baseline profile from the emulator to [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt). Then copy the resulting baseline profile from the emulator to [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt).
## Compose compiler metrics
Run the following command to get and analyse compose compiler metrics:
```
./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true
```
The reports files will be added to build/compose-reports in each module. The metrics files will be
added to build/compose-metrics in each module.
For more information on Compose compiler metrics, see [this blog post](https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8).
# License # License
**Now in Android** is distributed under the terms of the Apache License (Version 2.0). See the **Now in Android** is distributed under the terms of the Apache License (Version 2.0). See the

@ -36,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
@ -206,13 +205,13 @@ fun NiaCatalog() {
onCheckedChange = { checked -> firstChecked = checked }, onCheckedChange = { checked -> firstChecked = checked },
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), imageVector = NiaIcons.BookmarkBorder,
contentDescription = null, contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), imageVector = NiaIcons.Bookmark,
contentDescription = null, contentDescription = null,
) )
}, },
@ -223,13 +222,13 @@ fun NiaCatalog() {
onCheckedChange = { checked -> secondChecked = checked }, onCheckedChange = { checked -> secondChecked = checked },
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), imageVector = NiaIcons.BookmarkBorder,
contentDescription = null, contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), imageVector = NiaIcons.Bookmark,
contentDescription = null, contentDescription = null,
) )
}, },
@ -239,13 +238,13 @@ fun NiaCatalog() {
onCheckedChange = {}, onCheckedChange = {},
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), imageVector = NiaIcons.BookmarkBorder,
contentDescription = null, contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), imageVector = NiaIcons.Bookmark,
contentDescription = null, contentDescription = null,
) )
}, },
@ -256,13 +255,13 @@ fun NiaCatalog() {
onCheckedChange = {}, onCheckedChange = {},
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), imageVector = NiaIcons.BookmarkBorder,
contentDescription = null, contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), imageVector = NiaIcons.Bookmark,
contentDescription = null, contentDescription = null,
) )
}, },
@ -334,40 +333,31 @@ fun NiaCatalog() {
item { Text("Navigation", Modifier.padding(top = 16.dp)) } item { Text("Navigation", Modifier.padding(top = 16.dp)) }
item { item {
var selectedItem by remember { mutableStateOf(0) } var selectedItem by remember { mutableStateOf(0) }
val items = listOf("For you", "Episodes", "Saved", "Interests") val items = listOf("For you", "Saved", "Interests")
val icons = listOf( val icons = listOf(
NiaIcons.UpcomingBorder, NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder, NiaIcons.BookmarksBorder,
NiaIcons.Grid3x3,
) )
val selectedIcons = listOf( val selectedIcons = listOf(
NiaIcons.Upcoming, NiaIcons.Upcoming,
NiaIcons.MenuBook,
NiaIcons.Bookmarks, NiaIcons.Bookmarks,
NiaIcons.Grid3x3,
) )
val tagIcon = NiaIcons.Tag
NiaNavigationBar { NiaNavigationBar {
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
NiaNavigationBarItem( NiaNavigationBarItem(
icon = { icon = {
if (index == 3) {
Icon(imageVector = tagIcon, contentDescription = null)
} else {
Icon( Icon(
painter = painterResource(id = icons[index]), imageVector = icons[index],
contentDescription = item, contentDescription = item,
) )
}
}, },
selectedIcon = { selectedIcon = {
if (index == 3) {
Icon(imageVector = tagIcon, contentDescription = null)
} else {
Icon( Icon(
painter = painterResource(id = selectedIcons[index]), imageVector = selectedIcons[index],
contentDescription = item, contentDescription = item,
) )
}
}, },
label = { Text(item) }, label = { Text(item) },
selected = selectedItem == index, selected = selectedItem == index,

@ -33,6 +33,7 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -66,9 +67,15 @@ class NavigationTest {
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/** /**
* Use the primary activity to initialize the app normally. * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
*/ */
@get:Rule(order = 2) @get:Rule(order = 2)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use the primary activity to initialize the app normally.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<MainActivity>() val composeTestRule = createAndroidComposeRule<MainActivity>()
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =

@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness import com.google.accompanist.testharness.TestHarness
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.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
@ -61,9 +62,15 @@ class NavigationUiTest {
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/** /**
* Use a test activity to set the content on. * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
*/ */
@get:Rule(order = 2) @get:Rule(order = 2)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>() val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository( val userNewsResourceRepository = CompositeUserNewsResourceRepository(

@ -39,6 +39,7 @@ import com.google.samples.apps.nowinandroid.ui.NiaAppState
@Composable @Composable
fun NiaNavHost( fun NiaNavHost(
appState: NiaAppState, appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute, startDestination: String = forYouNavigationRoute,
) { ) {
@ -50,7 +51,10 @@ fun NiaNavHost(
) { ) {
// TODO: handle topic clicks from each top level destination // TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {}) forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {}) bookmarksScreen(
onTopicClick = navController::navigateToTopic,
onShowSnackbar = onShowSnackbar,
)
searchScreen( searchScreen(
onBackClick = navController::popBackStack, onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },

@ -16,10 +16,8 @@
package com.google.samples.apps.nowinandroid.navigation package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
@ -31,26 +29,26 @@ import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR
* next within a single destination will be handled directly in composables. * next within a single destination will be handled directly in composables.
*/ */
enum class TopLevelDestination( enum class TopLevelDestination(
val selectedIcon: Icon, val selectedIcon: ImageVector,
val unselectedIcon: Icon, val unselectedIcon: ImageVector,
val iconTextId: Int, val iconTextId: Int,
val titleTextId: Int, val titleTextId: Int,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming), selectedIcon = NiaIcons.Upcoming,
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.for_you, iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name, titleTextId = R.string.app_name,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.saved, iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved, titleTextId = bookmarksR.string.saved,
), ),
INTERESTS( INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), unselectedIcon = NiaIcons.Grid3x3,
iconTextId = interestsR.string.interests, iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests, titleTextId = interestsR.string.interests,
), ),

@ -15,6 +15,7 @@
*/ */
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -32,8 +33,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
@ -46,11 +49,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
@ -68,8 +71,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
@ -129,6 +130,8 @@ fun NiaApp(
) )
} }
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
Scaffold( Scaffold(
modifier = Modifier.semantics { modifier = Modifier.semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
@ -139,7 +142,6 @@ fun NiaApp(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if (appState.shouldShowBottomBar) { if (appState.shouldShowBottomBar) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
NiaBottomBar( NiaBottomBar(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations, destinationsWithUnreadResources = unreadDestinations,
@ -164,6 +166,7 @@ fun NiaApp(
if (appState.shouldShowNavRail) { if (appState.shouldShowNavRail) {
NiaNavRail( NiaNavRail(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentDestination,
modifier = Modifier modifier = Modifier
@ -194,7 +197,13 @@ fun NiaApp(
) )
} }
NiaNavHost(appState) NiaNavHost(appState = appState, onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
})
} }
// TODO: We may want to add padding or spacer when the snackbar is shown so that // TODO: We may want to add padding or spacer when the snackbar is shown so that
@ -208,6 +217,7 @@ fun NiaApp(
@Composable @Composable
private fun NiaNavRail( private fun NiaNavRail(
destinations: List<TopLevelDestination>, destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?, currentDestination: NavDestination?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -215,29 +225,24 @@ private fun NiaNavRail(
NiaNavigationRail(modifier = modifier) { NiaNavigationRail(modifier = modifier) {
destinations.forEach { destination -> destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
val hasUnread = destinationsWithUnreadResources.contains(destination)
NiaNavigationRailItem( NiaNavigationRailItem(
selected = selected, selected = selected,
onClick = { onNavigateToDestination(destination) }, onClick = { onNavigateToDestination(destination) },
icon = { icon = {
val icon = if (selected) { Icon(
destination.selectedIcon imageVector = destination.unselectedIcon,
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null, contentDescription = null,
) )
},
is DrawableResourceIcon -> Icon( selectedIcon = {
painter = painterResource(id = icon.id), Icon(
imageVector = destination.selectedIcon,
contentDescription = null, contentDescription = null,
) )
}
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
) )
} }
} }
@ -261,34 +266,28 @@ private fun NiaBottomBar(
selected = selected, selected = selected,
onClick = { onNavigateToDestination(destination) }, onClick = { onNavigateToDestination(destination) },
icon = { icon = {
val icon = if (selected) { Icon(
destination.selectedIcon imageVector = destination.unselectedIcon,
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null, contentDescription = null,
) )
},
is DrawableResourceIcon -> Icon( selectedIcon = {
painter = painterResource(id = icon.id), Icon(
imageVector = destination.selectedIcon,
contentDescription = null, contentDescription = null,
) )
}
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) notificationDot() else Modifier, modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
) )
} }
} }
} }
@Composable private fun Modifier.notificationDot(): Modifier =
private fun notificationDot(): Modifier { composed {
val tertiaryColor = MaterialTheme.colorScheme.tertiary val tertiaryColor = MaterialTheme.colorScheme.tertiary
return Modifier.drawWithContent { drawWithContent {
drawContent() drawContent()
drawCircle( drawCircle(
tertiaryColor, tertiaryColor,
@ -302,7 +301,7 @@ private fun notificationDot(): Modifier {
), ),
) )
} }
} }
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any { this?.hierarchy?.any {

@ -13,7 +13,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.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice
import com.google.samples.apps.nowinandroid.NiaBuildType import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors

@ -0,0 +1,44 @@
/*
* 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
import android.Manifest.permission
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.TIRAMISU
import androidx.benchmark.macro.MacrobenchmarkScope
/**
* Because the app under test is different from the one running the instrumentation test,
* the permission has to be granted manually by either:
*
* - tapping the Allow button
* ```kotlin
* val obj = By.text("Allow")
* val dialog = device.wait(Until.findObject(obj), TIMEOUT)
* dialog?.let {
* it.click()
* device.wait(Until.gone(obj), 5_000)
* }
* ```
* - or (preferred) executing the grant command on the target package.
*/
fun MacrobenchmarkScope.allowNotifications() {
if (SDK_INT >= TIRAMISU) {
val command = "pm grant $packageName ${permission.POST_NOTIFICATIONS}"
device.executeShellCommand(command)
}
}

@ -52,8 +52,6 @@ class BaselineProfileGenerator {
// Navigate to saved screen // Navigate to saved screen
goToBookmarksScreen() goToBookmarksScreen()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp()
// Navigate to interests screen // Navigate to interests screen
goToInterestsScreen() goToInterestsScreen()

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.goToBookmarksScreen() { fun MacrobenchmarkScope.goToBookmarksScreen() {
device.findObject(By.text("Saved")).click() device.findObject(By.text("Saved")).click()
@ -29,8 +28,3 @@ fun MacrobenchmarkScope.goToBookmarksScreen() {
val topAppBar = device.findObject(By.res("niaTopAppBar")) val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000) topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000)
} }
fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() {
val feedList = device.findObject(By.res("bookmarks:feed"))
device.flingElementDownUp(feedList)
}

@ -22,6 +22,7 @@ import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -47,6 +48,7 @@ class ScrollForYouFeedBenchmark {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()
allowNotifications()
}, },
) { ) {
forYouWaitForContent() forYouWaitForContent()

@ -0,0 +1,62 @@
/*
* 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.interests
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollTopicListBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun benchmarkStateChangeCompilationBaselineProfile() =
benchmarkStateChange(CompilationMode.Partial())
private fun benchmarkStateChange(compilationMode: CompilationMode) =
benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(FrameTimingMetric()),
compilationMode = compilationMode,
iterations = 10,
startupMode = StartupMode.WARM,
setupBlock = {
// Start the app
pressHome()
startActivityAndWait()
allowNotifications()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()
},
) {
interestsWaitForTopics()
repeat(3) {
interestsScrollTopicsDownUp()
}
}
}

@ -23,6 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -47,7 +48,7 @@ class TopicsScreenRecompositionBenchmark {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()
allowNotifications()
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()
device.waitForIdle() device.waitForIdle()

@ -16,20 +16,16 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.plugins.ExtensionAware
import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.withType import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/** /**
@ -92,9 +88,6 @@ private fun Project.configureKotlin() {
allWarningsAsErrors = warningsAsErrors.toBoolean() allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf( freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
) )
} }
} }

@ -4,7 +4,6 @@ import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ApplicationProductFlavor import com.android.build.api.dsl.ApplicationProductFlavor
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ProductFlavor import com.android.build.api.dsl.ProductFlavor
import org.gradle.api.Project
@Suppress("EnumEntryName") @Suppress("EnumEntryName")
enum class FlavorDimension { enum class FlavorDimension {
@ -17,10 +16,10 @@ enum class FlavorDimension {
@Suppress("EnumEntryName") @Suppress("EnumEntryName")
enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) { enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"), demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType, ) prod(FlavorDimension.contentType)
} }
fun Project.configureFlavors( fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *>, commonExtension: CommonExtension<*, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {} flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) { ) {
@ -33,7 +32,7 @@ fun Project.configureFlavors(
flavorConfigurationBlock(this, it) flavorConfigurationBlock(this, it)
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) { if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.applicationIdSuffix != null) { if (it.applicationIdSuffix != null) {
this.applicationIdSuffix = it.applicationIdSuffix applicationIdSuffix = it.applicationIdSuffix
} }
} }
} }

@ -34,7 +34,6 @@ data class AnalyticsEvent(
class Types { class Types {
companion object { companion object {
const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME) const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME)
const val VIEW_SEARCH_RESULTS = "view_search_results" // (extras: SEARCH_TERM)
} }
} }
@ -54,7 +53,6 @@ data class AnalyticsEvent(
class ParamKeys { class ParamKeys {
companion object { companion object {
const val SCREEN_NAME = "screen_name" const val SCREEN_NAME = "screen_name"
const val SEARCH_TERM = "search_term"
} }
} }
} }

@ -0,0 +1,44 @@
/*
* 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.network.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object CoroutineScopesModule {
@Provides
@Singleton
@ApplicationScope
fun providesCoroutineScope(
@Dispatcher(Default) dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}

@ -25,4 +25,5 @@ android {
dependencies { dependencies {
api(project(":core:data")) api(project(":core:data"))
implementation(project(":core:testing")) implementation(project(":core:testing"))
implementation(project(":core:common"))
} }

@ -20,6 +20,7 @@ import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
@ -29,6 +30,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -45,7 +47,12 @@ class DefaultSearchContentsRepository @Inject constructor(
override suspend fun populateFtsData() { override suspend fun populateFtsData() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
newsResourceFtsDao.insertAll( newsResourceFtsDao.insertAll(
newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() }, newsResourceDao.getNewsResources(
useFilterTopicIds = false,
useFilterNewsIds = false,
)
.first()
.map(PopulatedNewsResource::asFtsEntity),
) )
topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() }) topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })
} }

@ -21,6 +21,7 @@ import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback import android.net.ConnectivityManager.NetworkCallback
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.NetworkRequest.Builder import android.net.NetworkRequest.Builder
import android.os.Build.VERSION import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
@ -44,36 +45,33 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
} }
/** /**
* Sends the latest connectivity status to the underlying channel. * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest],
*/ * not just the active network. So we can simply track the presence (or absence) of such [Network].
fun update() {
channel.trySend(connectivityManager.isCurrentlyConnected())
}
/**
* The callback's methods are invoked on changes to *any* network, not just the active
* network. So to check for network connectivity, one must query the active network of the
* ConnectivityManager.
*/ */
val callback = object : NetworkCallback() { val callback = object : NetworkCallback() {
override fun onAvailable(network: Network) = update()
override fun onLost(network: Network) = update() private val networks = mutableSetOf<Network>()
override fun onAvailable(network: Network) {
networks += network
channel.trySend(true)
}
override fun onCapabilitiesChanged( override fun onLost(network: Network) {
network: Network, networks -= network
networkCapabilities: NetworkCapabilities, channel.trySend(networks.isNotEmpty())
) = update() }
} }
connectivityManager.registerNetworkCallback( val request = Builder()
Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(), .build()
callback, connectivityManager.registerNetworkCallback(request, callback)
)
update() /**
* Sends the latest connectivity status to the underlying channel.
*/
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose { awaitClose {
connectivityManager.unregisterNetworkCallback(callback) connectivityManager.unregisterNetworkCallback(callback)
@ -87,6 +85,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
activeNetwork activeNetwork
?.let(::getNetworkCapabilities) ?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
else -> activeNetworkInfo?.isConnected else -> activeNetworkInfo?.isConnected
} ?: false } ?: false
} }

@ -67,8 +67,6 @@ class TestNewsResourceDao : NewsResourceDao {
result result
} }
override suspend fun getOneOffNewsResources(): List<PopulatedNewsResource> = emptyList()
override suspend fun insertOrIgnoreNewsResources( override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>, entities: List<NewsResourceEntity>,
): List<Long> { ): List<Long> {

@ -65,10 +65,6 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(), filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>> ): Flow<List<PopulatedNewsResource>>
@Transaction
@Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC")
suspend fun getOneOffNewsResources(): List<PopulatedNewsResource>
/** /**
* Inserts [entities] into the db if they don't exist, and ignores those that do * Inserts [entities] into the db if they don't exist, and ignores those that do
*/ */

@ -36,9 +36,3 @@ data class NewsResourceFtsEntity(
@ColumnInfo(name = "content") @ColumnInfo(name = "content")
val content: String, val content: String,
) )
fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity(
newsResourceId = id,
title = title,
content = content,
)

@ -26,6 +26,7 @@ dependencies {
api(project(":core:datastore")) api(project(":core:datastore"))
api(libs.androidx.dataStore.core) api(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:testing")) implementation(project(":core:testing"))
} }

@ -21,15 +21,12 @@ import androidx.datastore.core.DataStoreFactory
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import javax.inject.Singleton import javax.inject.Singleton
@ -43,13 +40,12 @@ object TestDataStoreModule {
@Provides @Provides
@Singleton @Singleton
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer, userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder, tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore( tmpFolder.testUserPreferencesDataStore(
// TODO: Provide an application-wide CoroutineScope in the DI graph coroutineScope = scope,
coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher),
userPreferencesSerializer = userPreferencesSerializer, userPreferencesSerializer = userPreferencesSerializer,
) )
} }

@ -143,13 +143,13 @@ class NiaPreferencesDataSource @Inject constructor(
} }
suspend fun setNewsResourcesViewed(newsResourceIds: List<String>, viewed: Boolean) { suspend fun setNewsResourcesViewed(newsResourceIds: List<String>, viewed: Boolean) {
userPreferences.updateData { userPreferences.updateData { prefs ->
it.copy { prefs.copy {
newsResourceIds.forEach { newsResourceIds.forEach { id ->
if (viewed) { if (viewed) {
viewedNewsResourceIds.put(it, true) viewedNewsResourceIds.put(id, true)
} else { } else {
viewedNewsResourceIds.remove(it) viewedNewsResourceIds.remove(id)
} }
} }
} }

@ -32,7 +32,6 @@ class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferenc
override suspend fun readFrom(input: InputStream): UserPreferences = override suspend fun readFrom(input: InputStream): UserPreferences =
try { try {
// readFrom is already called on the data store background thread // readFrom is already called on the data store background thread
@Suppress("BlockingMethodInNonBlockingContext")
UserPreferences.parseFrom(input) UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) { } catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception) throw CorruptionException("Cannot read proto.", exception)
@ -40,7 +39,6 @@ class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferenc
override suspend fun writeTo(t: UserPreferences, output: OutputStream) { override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
// writeTo is already called on the data store background thread // writeTo is already called on the data store background thread
@Suppress("BlockingMethodInNonBlockingContext")
t.writeTo(output) t.writeTo(output)
} }
} }

@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -32,7 +33,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -44,11 +44,12 @@ object DataStoreModule {
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
@ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer, userPreferencesSerializer: UserPreferencesSerializer,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> =
DataStoreFactory.create( DataStoreFactory.create(
serializer = userPreferencesSerializer, serializer = userPreferencesSerializer,
scope = CoroutineScope(ioDispatcher + SupervisorJob()), scope = CoroutineScope(scope.coroutineContext + ioDispatcher),
migrations = listOf( migrations = listOf(
IntToStringIdsMigration, IntToStringIdsMigration,
), ),

@ -16,69 +16,45 @@
package com.google.samples.apps.nowinandroid.core.designsystem.icon package com.google.samples.apps.nowinandroid.core.designsystem.icon
import androidx.annotation.DrawableRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Bookmark
import androidx.compose.material.icons.rounded.BookmarkBorder
import androidx.compose.material.icons.rounded.Bookmarks
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.Fullscreen
import androidx.compose.material.icons.rounded.Grid3x3 import androidx.compose.material.icons.rounded.Grid3x3
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.ShortText import androidx.compose.material.icons.rounded.ShortText
import androidx.compose.material.icons.rounded.Tag import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material.icons.rounded.ViewDay import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.material.icons.rounded.VolumeOff
import androidx.compose.material.icons.rounded.VolumeUp
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.core.designsystem.R
/** /**
* Now in Android icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs. * Now in Android icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs.
*/ */
object NiaIcons { object NiaIcons {
val AccountCircle = Icons.Outlined.AccountCircle
val Add = Icons.Rounded.Add val Add = Icons.Rounded.Add
val ArrowBack = Icons.Rounded.ArrowBack val ArrowBack = Icons.Rounded.ArrowBack
val ArrowDropDown = Icons.Default.ArrowDropDown val Bookmark = Icons.Rounded.Bookmark
val ArrowDropUp = Icons.Default.ArrowDropUp val BookmarkBorder = Icons.Rounded.BookmarkBorder
val Bookmark = R.drawable.ic_bookmark val Bookmarks = Icons.Rounded.Bookmarks
val BookmarkBorder = R.drawable.ic_bookmark_border val BookmarksBorder = Icons.Outlined.Bookmarks
val Bookmarks = R.drawable.ic_bookmarks
val BookmarksBorder = R.drawable.ic_bookmarks_border
val Check = Icons.Rounded.Check val Check = Icons.Rounded.Check
val Close = Icons.Rounded.Close val Close = Icons.Rounded.Close
val ExpandLess = Icons.Rounded.ExpandLess
val Fullscreen = Icons.Rounded.Fullscreen
val Grid3x3 = Icons.Rounded.Grid3x3 val Grid3x3 = Icons.Rounded.Grid3x3
val MenuBook = R.drawable.ic_menu_book
val MenuBookBorder = R.drawable.ic_menu_book_border
val MoreVert = Icons.Default.MoreVert val MoreVert = Icons.Default.MoreVert
val Person = Icons.Rounded.Person val Person = Icons.Rounded.Person
val PlayArrow = Icons.Rounded.PlayArrow
val Search = Icons.Rounded.Search val Search = Icons.Rounded.Search
val Settings = Icons.Rounded.Settings val Settings = Icons.Rounded.Settings
val ShortText = Icons.Rounded.ShortText val ShortText = Icons.Rounded.ShortText
val Tag = Icons.Rounded.Tag val Upcoming = Icons.Rounded.Upcoming
val Upcoming = R.drawable.ic_upcoming val UpcomingBorder = Icons.Outlined.Upcoming
val UpcomingBorder = R.drawable.ic_upcoming_border
val ViewDay = Icons.Rounded.ViewDay val ViewDay = Icons.Rounded.ViewDay
val VolumeOff = Icons.Rounded.VolumeOff
val VolumeUp = Icons.Rounded.VolumeUp
}
/**
* A sealed class to make dealing with [ImageVector] and [DrawableRes] icons easier.
*/
sealed class Icon {
data class ImageVectorIcon(val imageVector: ImageVector) : Icon()
data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon()
} }

@ -27,7 +27,6 @@ internal val Blue30 = Color(0xFF004D61)
internal val Blue40 = Color(0xFF006780) internal val Blue40 = Color(0xFF006780)
internal val Blue80 = Color(0xFF5DD5FC) internal val Blue80 = Color(0xFF5DD5FC)
internal val Blue90 = Color(0xFFB8EAFF) internal val Blue90 = Color(0xFFB8EAFF)
internal val Blue95 = Color(0xFFDDF4FF)
internal val DarkGreen10 = Color(0xFF0D1F12) internal val DarkGreen10 = Color(0xFF0D1F12)
internal val DarkGreen20 = Color(0xFF223526) internal val DarkGreen20 = Color(0xFF223526)
internal val DarkGreen30 = Color(0xFF394B3C) internal val DarkGreen30 = Color(0xFF394B3C)
@ -61,14 +60,12 @@ internal val Orange30 = Color(0xFF812800)
internal val Orange40 = Color(0xFFA23F16) internal val Orange40 = Color(0xFFA23F16)
internal val Orange80 = Color(0xFFFFB59B) internal val Orange80 = Color(0xFFFFB59B)
internal val Orange90 = Color(0xFFFFDBCF) internal val Orange90 = Color(0xFFFFDBCF)
internal val Orange95 = Color(0xFFFFEDE8)
internal val Purple10 = Color(0xFF36003C) internal val Purple10 = Color(0xFF36003C)
internal val Purple20 = Color(0xFF560A5D) internal val Purple20 = Color(0xFF560A5D)
internal val Purple30 = Color(0xFF702776) internal val Purple30 = Color(0xFF702776)
internal val Purple40 = Color(0xFF8B418F) internal val Purple40 = Color(0xFF8B418F)
internal val Purple80 = Color(0xFFFFA9FE) internal val Purple80 = Color(0xFFFFA9FE)
internal val Purple90 = Color(0xFFFFD6FA) internal val Purple90 = Color(0xFFFFD6FA)
internal val Purple95 = Color(0xFFFFEBFA)
internal val PurpleGray30 = Color(0xFF4D444C) internal val PurpleGray30 = Color(0xFF4D444C)
internal val PurpleGray50 = Color(0xFF7F747C) internal val PurpleGray50 = Color(0xFF7F747C)
internal val PurpleGray60 = Color(0xFF998D96) internal val PurpleGray60 = Color(0xFF998D96)

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M17,3H7C5.9,3 5,3.9 5,5V19.483C5,20.201 5.734,20.685 6.394,20.403L12,18L17.606,20.403C18.266,20.685 19,20.201 19,19.483V5C19,3.9 18.1,3 17,3Z"
android:fillColor="#201A1B"/>
</vector>

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M17,3H7C5.9,3 5,3.9 5,5V19.483C5,20.201 5.734,20.685 6.394,20.403L12,18L17.606,20.403C18.266,20.685 19,20.201 19,19.483V5C19,3.9 18.1,3 17,3ZM17,18L12.4,15.994C12.145,15.883 11.855,15.883 11.6,15.994L7,18V5H17V18Z"
android:fillColor="#201A1B"/>
</vector>

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,19C19,19.552 19.448,20 20,20C20.552,20 21,19.552 21,19V3C21,1.9 20.1,1 19,1H7C6.448,1 6,1.448 6,2C6,2.552 6.448,3 7,3H19V19Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M15,5H5C3.9,5 3,5.9 3,7V21.483C3,22.201 3.734,22.685 4.394,22.403L10,20L15.606,22.403C16.266,22.685 17,22.201 17,21.483V7C17,5.9 16.1,5 15,5Z"
android:fillColor="#201A1B"/>
</vector>

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,19C19,19.552 19.448,20 20,20C20.552,20 21,19.552 21,19V3C21,1.9 20.1,1 19,1H7C6.448,1 6,1.448 6,2C6,2.552 6.448,3 7,3H19V19Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M5,5H15C16.1,5 17,5.9 17,7V21.483C17,22.201 16.266,22.685 15.606,22.403L10,20L4.394,22.403C3.734,22.685 3,22.201 3,21.483V7C3,5.9 3.9,5 5,5ZM15,19.97V7H5V19.97C6.535,19.31 8.07,18.65 9.605,17.99C9.857,17.882 10.143,17.882 10.395,17.99L15,19.97Z"
android:fillColor="#201A1B"
android:fillType="evenOdd"/>
</vector>

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.005,5.178C13.455,4.078 15.555,3.678 17.505,3.678C18.955,3.678 20.495,3.898 21.775,4.468C22.505,4.798 22.995,5.508 22.995,6.318V17.598C22.995,18.908 21.775,19.868 20.515,19.538C19.535,19.288 18.495,19.178 17.495,19.178C15.935,19.178 14.275,19.428 12.935,20.098C12.345,20.398 11.665,20.398 11.065,20.098C9.725,19.438 8.065,19.178 6.505,19.178C5.505,19.178 4.465,19.288 3.485,19.538C2.225,19.858 1.005,18.898 1.005,17.598V6.318C1.005,5.508 1.495,4.798 2.225,4.468C3.515,3.898 5.055,3.678 6.505,3.678C8.455,3.678 10.555,4.078 12.005,5.178ZM21,17.5C19.9,17.15 18.7,17 17.5,17C16.16,17 14.37,17.41 13,17.99V6.49C14.37,5.9 16.16,5.5 17.5,5.5C18.7,5.5 19.9,5.65 21,6V17.5Z"
android:fillColor="#201A1B"
android:fillType="evenOdd"/>
<path
android:pathData="M17.5,9.5C18.38,9.5 19.23,9.59 20,9.76V8.24C19.21,8.09 18.36,8 17.5,8C16.22,8 15.04,8.16 14,8.47V10.04C14.99,9.69 16.18,9.5 17.5,9.5Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M17.5,12.16C18.38,12.16 19.23,12.25 20,12.42V10.9C19.21,10.75 18.36,10.66 17.5,10.66C16.22,10.66 15.04,10.82 14,11.13V12.7C14.99,12.36 16.18,12.16 17.5,12.16Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M17.5,14.83C18.38,14.83 19.23,14.92 20,15.09V13.57C19.21,13.42 18.36,13.33 17.5,13.33C16.22,13.33 15.04,13.49 14,13.8V15.37C14.99,15.02 16.18,14.83 17.5,14.83Z"
android:fillColor="#201A1B"/>
</vector>

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.005,5.178C13.455,4.078 15.555,3.678 17.505,3.678C18.955,3.678 20.495,3.898 21.775,4.468C22.505,4.798 22.995,5.508 22.995,6.318V17.598C22.995,18.908 21.775,19.868 20.515,19.538C19.535,19.288 18.495,19.178 17.495,19.178C15.935,19.178 14.275,19.428 12.935,20.098C12.345,20.398 11.665,20.398 11.065,20.098C9.725,19.438 8.065,19.178 6.505,19.178C5.505,19.178 4.465,19.288 3.485,19.538C2.225,19.858 1.005,18.898 1.005,17.598V6.318C1.005,5.508 1.495,4.798 2.225,4.468C3.515,3.898 5.055,3.678 6.505,3.678C8.455,3.678 10.555,4.078 12.005,5.178ZM6.5,5.5C7.84,5.5 9.63,5.91 11,6.49V17.99C9.63,17.41 7.84,17 6.5,17C5.3,17 4.1,17.15 3,17.5V6C4.1,5.65 5.3,5.5 6.5,5.5ZM21,17.5C19.9,17.15 18.7,17 17.5,17C16.16,17 14.37,17.41 13,17.99V6.49C14.37,5.9 16.16,5.5 17.5,5.5C18.7,5.5 19.9,5.65 21,6V17.5Z"
android:fillColor="#201A1B"
android:fillType="evenOdd"/>
<path
android:pathData="M17.5,9.5C18.38,9.5 19.23,9.59 20,9.76V8.24C19.21,8.09 18.36,8 17.5,8C16.22,8 15.04,8.16 14,8.47V10.04C14.99,9.69 16.18,9.5 17.5,9.5Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M17.5,12.16C18.38,12.16 19.23,12.25 20,12.42V10.9C19.21,10.75 18.36,10.66 17.5,10.66C16.22,10.66 15.04,10.82 14,11.13V12.7C14.99,12.36 16.18,12.16 17.5,12.16Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M17.5,14.83C18.38,14.83 19.23,14.92 20,15.09V13.57C19.21,13.42 18.36,13.33 17.5,13.33C16.22,13.33 15.04,13.49 14,13.8V15.37C14.99,15.02 16.18,14.83 17.5,14.83Z"
android:fillColor="#201A1B"/>
</vector>

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.45,6.55C20.07,6.17 19.44,6.17 19.06,6.55L16.89,8.7C16.5,9.08 16.5,9.71 16.89,10.09L16.9,10.1C17.29,10.49 17.91,10.49 18.3,10.1C18.92,9.47 19.82,8.56 20.45,7.93C20.83,7.55 20.83,6.93 20.45,6.55Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M12.02,3H11.99C11.44,3 11,3.44 11,3.98V7.01C11,7.56 11.44,8 11.98,8H12.01C12.56,8 13,7.56 13,7.02V3.98C13,3.44 12.56,3 12.02,3Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M7.1,10.11L7.11,10.1C7.49,9.72 7.49,9.09 7.11,8.71L4.96,6.54C4.58,6.15 3.95,6.15 3.57,6.54L3.55,6.55C3.16,6.94 3.16,7.56 3.55,7.94C4.18,8.56 5.08,9.48 5.7,10.11C6.09,10.49 6.72,10.49 7.1,10.11Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M12,15C10.76,15 9.69,14.25 9.24,13.17C8.92,12.43 8.14,12 7.34,12H4C2.9,12 2,12.9 2,14V19C2,20.1 2.9,21 4,21H20C21.1,21 22,20.1 22,19V14C22,12.9 21.1,12 20,12H16.66C15.86,12 15.08,12.43 14.76,13.17C14.31,14.25 13.24,15 12,15Z"
android:fillColor="#201A1B"/>
</vector>

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2022 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
http://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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.45,6.55C20.07,6.17 19.44,6.17 19.06,6.55L16.89,8.7C16.5,9.08 16.5,9.71 16.89,10.09L16.9,10.1C17.29,10.49 17.91,10.49 18.3,10.1C18.92,9.47 19.82,8.56 20.45,7.93C20.83,7.55 20.83,6.93 20.45,6.55Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M12.02,3H11.99C11.44,3 11,3.44 11,3.98V7.01C11,7.56 11.44,8 11.98,8H12.01C12.56,8 13,7.56 13,7.02V3.98C13,3.44 12.56,3 12.02,3Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M7.1,10.11L7.11,10.1C7.49,9.72 7.49,9.09 7.11,8.71L4.96,6.54C4.58,6.15 3.95,6.15 3.57,6.54L3.55,6.55C3.16,6.94 3.16,7.56 3.55,7.94C4.18,8.56 5.08,9.48 5.7,10.11C6.09,10.49 6.72,10.49 7.1,10.11Z"
android:fillColor="#201A1B"/>
<path
android:pathData="M9.24,13.17C9.69,14.25 10.76,15 12,15C13.24,15 14.31,14.25 14.76,13.17C15.08,12.43 15.86,12 16.66,12H20C21.1,12 22,12.9 22,14V19C22,20.1 21.1,21 20,21H4C2.9,21 2,20.1 2,19V14C2,12.9 2.9,12 4,12H7.34C8.14,12 8.92,12.43 9.24,13.17ZM20,14H16.58C15.81,15.76 14.04,17 12,17C9.96,17 8.19,15.76 7.42,14H4V19H20V14Z"
android:fillColor="#201A1B"
android:fillType="evenOdd"/>
</vector>

@ -15,7 +15,6 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="news_notification_title">Now in Android</string>
<string name="news_notification_channel_name">News updates</string> <string name="news_notification_channel_name">News updates</string>
<string name="news_notification_channel_description">The latest updates on what\'s new in Android</string> <string name="news_notification_channel_description">The latest updates on what\'s new in Android</string>
<string name="news_notification_group_summary">%1$d news updates</string> <string name="news_notification_group_summary">%1$d news updates</string>

@ -0,0 +1,29 @@
/*
* 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.rules
import android.Manifest.permission.POST_NOTIFICATIONS
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.TIRAMISU
import androidx.test.rule.GrantPermissionRule.grant
import org.junit.rules.TestRule
/**
* [TestRule] granting [POST_NOTIFICATIONS] permission if running on [SDK_INT] greater than [TIRAMISU].
*/
class GrantPostNotificationsPermissionRule :
TestRule by if (SDK_INT >= TIRAMISU) grant(POST_NOTIFICATIONS) else grant()

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.testing.di package com.google.samples.apps.nowinandroid.core.testing.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.DispatchersModule import com.google.samples.apps.nowinandroid.core.network.di.DispatchersModule
import dagger.Module import dagger.Module
@ -35,4 +36,10 @@ object TestDispatchersModule {
@Provides @Provides
@Dispatcher(IO) @Dispatcher(IO)
fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher
@Provides
@Dispatcher(Default)
fun providesDefaultDispatcher(
testDispatcher: TestDispatcher,
): CoroutineDispatcher = testDispatcher
} }

@ -112,22 +112,6 @@ class TestUserDataRepository : UserDataRepository {
} }
} }
/**
* A test-only API to allow setting/unsetting of bookmarks.
*
*/
fun setNewsResourceBookmarks(newsResourceIds: Set<String>) {
currentUserData.let { current ->
_userData.tryEmit(current.copy(bookmarkedNewsResources = newsResourceIds))
}
}
/**
* A test-only API to allow querying the current followed topics.
*/
fun getCurrentFollowedTopics(): Set<String>? =
_userData.replayCache.firstOrNull()?.followedTopics
/** /**
* A test-only API to allow setting of user data directly. * A test-only API to allow setting of user data directly.
*/ */

@ -183,13 +183,13 @@ fun BookmarkButton(
modifier = modifier, modifier = modifier,
icon = { icon = {
Icon( Icon(
painter = painterResource(NiaIcons.BookmarkBorder), imageVector = NiaIcons.BookmarkBorder,
contentDescription = stringResource(R.string.bookmark), contentDescription = stringResource(R.string.bookmark),
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(NiaIcons.Bookmark), imageVector = NiaIcons.Bookmark,
contentDescription = stringResource(R.string.unbookmark), contentDescription = stringResource(R.string.unbookmark),
) )
}, },
@ -250,14 +250,6 @@ fun NewsResourceMetaData(
) )
} }
@Composable
fun NewsResourceLink(
@Suppress("UNUSED_PARAMETER")
newsResource: NewsResource,
) {
TODO()
}
@Composable @Composable
fun NewsResourceShortDescription( fun NewsResourceShortDescription(
newsResourceShortDescription: String, newsResourceShortDescription: String,

@ -95,7 +95,7 @@ The Now in Android app contains the following types of modules:
* The `app` module - contains app level and scaffolding classes that bind the rest of the codebase, * The `app` module - contains app level and scaffolding classes that bind the rest of the codebase,
such as `MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is such as `MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is
the navigation setup through `NiaNavHost` and the bottom navigation bar setup the navigation setup through `NiaNavHost` and the bottom navigation bar setup
through `NiaTopLevelNavigation`. The `app` module depends on all `feature` modules and through `TopLevelDestination`. The `app` module depends on all `feature` modules and
required `core` modules. required `core` modules.
* `feature:` modules - feature specific modules which are scoped to handle a single responsibility * `feature:` modules - feature specific modules which are scoped to handle a single responsibility
@ -132,7 +132,7 @@ Using the above modularization strategy, the Now in Android app has the followin
<td>Brings everything together required for the app to function correctly. This includes UI scaffolding and navigation. <td>Brings everything together required for the app to function correctly. This includes UI scaffolding and navigation.
</td> </td>
<td><code>NiaApp, MainActivity</code><br> <td><code>NiaApp, MainActivity</code><br>
App-level controlled navigation via <code>NiaNavHost, NiaTopLevelNavigation</code> App-level controlled navigation via <code>NiaNavHost, NiaAppState, TopLevelDestination</code>
</td> </td>
</tr> </tr>
<tr> <tr>

@ -50,6 +50,7 @@ class BookmarksScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BookmarksScreen( BookmarksScreen(
feedState = NewsFeedUiState.Loading, feedState = NewsFeedUiState.Loading,
onShowSnackbar = { _, _ -> false },
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourceViewed = {}, onNewsResourceViewed = {},
@ -70,6 +71,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
userNewsResourcesTestData.take(2), userNewsResourcesTestData.take(2),
), ),
onShowSnackbar = { _, _ -> false },
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourceViewed = {}, onNewsResourceViewed = {},
@ -110,6 +112,7 @@ class BookmarksScreenTest {
feedState = NewsFeedUiState.Success( feedState = NewsFeedUiState.Success(
userNewsResourcesTestData.take(2), userNewsResourcesTestData.take(2),
), ),
onShowSnackbar = { _, _ -> false },
removeFromBookmarks = { newsResourceId -> removeFromBookmarks = { newsResourceId ->
assertEquals(userNewsResourcesTestData[0].id, newsResourceId) assertEquals(userNewsResourcesTestData[0].id, newsResourceId)
removeFromBookmarksCalled = true removeFromBookmarksCalled = true
@ -144,6 +147,7 @@ class BookmarksScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
BookmarksScreen( BookmarksScreen(
feedState = NewsFeedUiState.Success(emptyList()), feedState = NewsFeedUiState.Success(emptyList()),
onShowSnackbar = { _, _ -> false },
removeFromBookmarks = {}, removeFromBookmarks = {},
onTopicClick = {}, onTopicClick = {},
onNewsResourceViewed = {}, onNewsResourceViewed = {},

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -35,19 +34,12 @@ import androidx.compose.foundation.lazy.grid.GridCells.Adaptive
import androidx.compose.foundation.lazy.grid.GridItemSpan 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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.ColorFilter import androidx.compose.ui.graphics.ColorFilter
@ -79,12 +71,14 @@ import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable @Composable
internal fun BookmarksRoute( internal fun BookmarksRoute(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: BookmarksViewModel = hiltViewModel(), viewModel: BookmarksViewModel = hiltViewModel(),
) { ) {
val feedState by viewModel.feedUiState.collectAsStateWithLifecycle() val feedState by viewModel.feedUiState.collectAsStateWithLifecycle()
BookmarksScreen( BookmarksScreen(
feedState = feedState, feedState = feedState,
onShowSnackbar = onShowSnackbar,
removeFromBookmarks = viewModel::removeFromSavedResources, removeFromBookmarks = viewModel::removeFromSavedResources,
onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) },
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
@ -98,11 +92,11 @@ internal fun BookmarksRoute(
/** /**
* Displays the user's bookmarked articles. Includes support for loading and empty states. * Displays the user's bookmarked articles. Includes support for loading and empty states.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable @Composable
internal fun BookmarksScreen( internal fun BookmarksScreen(
feedState: NewsFeedUiState, feedState: NewsFeedUiState,
onShowSnackbar: suspend (String, String?) -> Boolean,
removeFromBookmarks: (String) -> Unit, removeFromBookmarks: (String) -> Unit,
onNewsResourceViewed: (String) -> Unit, onNewsResourceViewed: (String) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
@ -113,18 +107,14 @@ internal fun BookmarksScreen(
) { ) {
val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed) val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed)
val undoText = stringResource(id = R.string.undo) val undoText = stringResource(id = R.string.undo)
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(shouldDisplayUndoBookmark) { LaunchedEffect(shouldDisplayUndoBookmark) {
if (shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) {
val snackBarResult = snackbarHostState.showSnackbar( val snackBarResult = onShowSnackbar(bookmarkRemovedMessage, undoText)
message = bookmarkRemovedMessage, if (snackBarResult) {
actionLabel = undoText, undoBookmarkRemoval()
duration = Short, } else {
) clearUndoState()
when (snackBarResult) {
ActionPerformed -> { undoBookmarkRemoval() }
else -> { clearUndoState() }
} }
} }
} }
@ -140,20 +130,21 @@ internal fun BookmarksScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
} }
Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarHostState) }) {
Box(
modifier = Modifier.padding(it).fillMaxSize(),
) {
when (feedState) { when (feedState) {
Loading -> LoadingState(modifier) Loading -> LoadingState(modifier)
is Success -> if (feedState.feed.isNotEmpty()) { is Success -> if (feedState.feed.isNotEmpty()) {
BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) BookmarksGrid(
feedState,
removeFromBookmarks,
onNewsResourceViewed,
onTopicClick,
modifier,
)
} else { } else {
EmptyState(modifier) EmptyState(modifier)
} }
} }
}
}
TrackScreenViewEvent(screenName = "Saved") TrackScreenViewEvent(screenName = "Saved")
} }

@ -28,8 +28,11 @@ fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) {
this.navigate(bookmarksRoute, navOptions) this.navigate(bookmarksRoute, navOptions)
} }
fun NavGraphBuilder.bookmarksScreen(onTopicClick: (String) -> Unit) { fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
composable(route = bookmarksRoute) { composable(route = bookmarksRoute) {
BookmarksRoute(onTopicClick) BookmarksRoute(onTopicClick, onShowSnackbar)
} }
} }

@ -17,9 +17,6 @@
<resources> <resources>
<string name="saved">Saved</string> <string name="saved">Saved</string>
<string name="saved_loading">Loading saved…</string> <string name="saved_loading">Loading saved…</string>
<string name="top_app_bar_title">Saved</string>
<string name="top_app_bar_action_search">Search</string>
<string name="top_app_bar_action_menu">Menu</string>
<string name="bookmarks_empty_error">No saved updates</string> <string name="bookmarks_empty_error">No saved updates</string>
<string name="bookmarks_empty_description">Updates you save will be stored here\nto read later</string> <string name="bookmarks_empty_description">Updates you save will be stored here\nto read later</string>
<string name="bookmark_removed">Bookmark removed</string> <string name="bookmark_removed">Bookmark removed</string>

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
id("nowinandroid.android.feature") id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose") id("nowinandroid.android.library.compose")

@ -28,6 +28,7 @@ import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -35,7 +36,11 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
class ForYouScreenTest { class ForYouScreenTest {
@get:Rule
@get:Rule(order = 0)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<ComponentActivity>() val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val doneButtonMatcher by lazy { private val doneButtonMatcher by lazy {
@ -98,7 +103,7 @@ class ForYouScreenTest {
@Test @Test
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
val testData = followableTopicTestData.map { it -> it.copy(isFollowed = false) } val testData = followableTopicTestData.map { it.copy(isFollowed = false) }
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { BoxWithConstraints {

@ -68,6 +68,7 @@ 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -406,6 +407,9 @@ fun TopicIcon(
@Composable @Composable
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
private fun NotificationPermissionEffect() { private fun NotificationPermissionEffect() {
// Permission requests should only be made from an Activity Context, which is not present
// in previews
if (LocalInspectionMode.current) return
if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return
val notificationsPermissionState = rememberPermissionState( val notificationsPermissionState = rememberPermissionState(
android.Manifest.permission.POST_NOTIFICATIONS, android.Manifest.permission.POST_NOTIFICATIONS,

@ -21,13 +21,5 @@
<string name="navigate_up">Navigate up</string> <string name="navigate_up">Navigate up</string>
<string name="onboarding_guidance_title">What are you interested in?</string> <string name="onboarding_guidance_title">What are you interested in?</string>
<string name="onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string> <string name="onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string>
<string name="top_app_bar_title">Now in Android</string>
<string name="for_you_top_app_bar_action_search">Search</string>
<!-- Authors-->
<string name="following">You are following</string>
<string name="not_following">You are not following</string>
<string name="follow">Follow</string>
<string name="unfollow">Unfollow</string>
</resources> </resources>

@ -30,7 +30,6 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRe
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule 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.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 com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
@ -54,7 +53,6 @@ class ForYouViewModelTest {
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val networkMonitor = TestNetworkMonitor()
private val syncManager = TestSyncManager() private val syncManager = TestSyncManager()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
id("nowinandroid.android.feature") id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose") id("nowinandroid.android.library.compose")

@ -20,7 +20,4 @@
<string name="empty_header">"No available data"</string> <string name="empty_header">"No available data"</string>
<string name="card_follow_button_content_desc">Follow interest</string> <string name="card_follow_button_content_desc">Follow interest</string>
<string name="card_unfollow_button_content_desc">Unfollow interest</string> <string name="card_unfollow_button_content_desc">Unfollow interest</string>
<string name="top_app_bar_title">Interests</string>
<string name="top_app_bar_action_menu">Menu</string>
<string name="top_app_bar_action_search">Search</string>
</resources> </resources>

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
id("nowinandroid.android.feature") id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose") id("nowinandroid.android.library.compose")

@ -40,7 +40,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -83,7 +82,6 @@ fun SettingsDialog(
) )
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun SettingsDialog( fun SettingsDialog(
settingsUiState: SettingsUiState, settingsUiState: SettingsUiState,

@ -18,7 +18,7 @@
<string name="top_app_bar_action_icon_description">Settings</string> <string name="top_app_bar_action_icon_description">Settings</string>
<string name="top_app_bar_navigation_icon_description">Search</string> <string name="top_app_bar_navigation_icon_description">Search</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="loading">Loading...</string> <string name="loading">Loading</string>
<string name="privacy_policy">Privacy policy</string> <string name="privacy_policy">Privacy policy</string>
<string name="licenses">Licenses</string> <string name="licenses">Licenses</string>
<string name="brand_guidelines">Brand Guidelines</string> <string name="brand_guidelines">Brand Guidelines</string>

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
id("nowinandroid.android.feature") id("nowinandroid.android.feature")
id("nowinandroid.android.library.compose") id("nowinandroid.android.library.compose")

@ -59,7 +59,6 @@ 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.userNewsResourceCardItems import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@Composable @Composable
internal fun TopicRoute( internal fun TopicRoute(
@ -107,7 +106,7 @@ internal fun TopicScreen(
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
} }
when (topicUiState) { when (topicUiState) {
Loading -> item { TopicUiState.Loading -> item {
NiaLoadingWheel( NiaLoadingWheel(
modifier = modifier, modifier = modifier,
contentDesc = stringResource(id = string.topic_loading), contentDesc = stringResource(id = string.topic_loading),

@ -15,6 +15,5 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<string name="topic">Topic</string>
<string name="topic_loading">Loading topic</string> <string name="topic_loading">Loading topic</string>
</resources> </resources>

@ -1,7 +1,7 @@
[versions] [versions]
accompanist = "0.28.0" accompanist = "0.28.0"
androidDesugarJdkLibs = "1.2.2" androidDesugarJdkLibs = "1.2.2"
androidGradlePlugin = "8.0.1" androidGradlePlugin = "8.0.2"
androidxActivity = "1.7.0" androidxActivity = "1.7.0"
androidxAppCompat = "1.5.1" androidxAppCompat = "1.5.1"
androidxBrowser = "1.4.0" androidxBrowser = "1.4.0"
@ -42,7 +42,7 @@ junit4 = "4.13.2"
kotlin = "1.8.20" kotlin = "1.8.20"
kotlinxCoroutines = "1.6.4" kotlinxCoroutines = "1.6.4"
kotlinxDatetime = "0.4.0" kotlinxDatetime = "0.4.0"
kotlinxSerializationJson = "1.5.0" kotlinxSerializationJson = "1.5.1"
ksp = "1.8.20-1.0.11" ksp = "1.8.20-1.0.11"
lint = "30.3.1" lint = "30.3.1"
okhttp = "4.10.0" okhttp = "4.10.0"

@ -32,7 +32,6 @@ import org.jetbrains.uast.UQualifiedReferenceExpression
* A detector that checks for incorrect usages of Compose Material APIs over equivalents in * A detector that checks for incorrect usages of Compose Material APIs over equivalents in
* the Now in Android design system module. * the Now in Android design system module.
*/ */
@Suppress("UnstableApiUsage")
class DesignSystemDetector : Detector(), Detector.UastScanner { class DesignSystemDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes(): List<Class<out UElement>> { override fun getApplicableUastTypes(): List<Class<out UElement>> {

@ -24,7 +24,6 @@ import com.android.tools.lint.detector.api.CURRENT_API
* An issue registry that checks for incorrect usages of Compose Material APIs over equivalents in * An issue registry that checks for incorrect usages of Compose Material APIs over equivalents in
* the Now in Android design system module. * the Now in Android design system module.
*/ */
@Suppress("UnstableApiUsage")
class DesignSystemIssueRegistry : IssueRegistry() { class DesignSystemIssueRegistry : IssueRegistry() {
override val issues = listOf(DesignSystemDetector.ISSUE) override val issues = listOf(DesignSystemDetector.ISSUE)

Loading…
Cancel
Save