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:
- main
pull_request:
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
@ -100,7 +101,7 @@ jobs:
disable-animations: true
disk-size: 6000M
heap-size: 600M
script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon
script: ./gradlew connectedDemoDebugAndroidTest --daemon
- name: Upload test reports
if: always()
@ -108,3 +109,42 @@ jobs:
with:
name: test-reports-${{ matrix.api-level }}
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).
# 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).
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.
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
**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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.NiaFilterChip
@ -206,13 +205,13 @@ fun NiaCatalog() {
onCheckedChange = { checked -> firstChecked = checked },
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
imageVector = NiaIcons.BookmarkBorder,
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
imageVector = NiaIcons.Bookmark,
contentDescription = null,
)
},
@ -223,13 +222,13 @@ fun NiaCatalog() {
onCheckedChange = { checked -> secondChecked = checked },
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
imageVector = NiaIcons.BookmarkBorder,
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
imageVector = NiaIcons.Bookmark,
contentDescription = null,
)
},
@ -239,13 +238,13 @@ fun NiaCatalog() {
onCheckedChange = {},
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
imageVector = NiaIcons.BookmarkBorder,
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
imageVector = NiaIcons.Bookmark,
contentDescription = null,
)
},
@ -256,13 +255,13 @@ fun NiaCatalog() {
onCheckedChange = {},
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
imageVector = NiaIcons.BookmarkBorder,
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
imageVector = NiaIcons.Bookmark,
contentDescription = null,
)
},
@ -334,40 +333,31 @@ fun NiaCatalog() {
item { Text("Navigation", Modifier.padding(top = 16.dp)) }
item {
var selectedItem by remember { mutableStateOf(0) }
val items = listOf("For you", "Episodes", "Saved", "Interests")
val items = listOf("For you", "Saved", "Interests")
val icons = listOf(
NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder,
NiaIcons.Grid3x3,
)
val selectedIcons = listOf(
NiaIcons.Upcoming,
NiaIcons.MenuBook,
NiaIcons.Bookmarks,
NiaIcons.Grid3x3,
)
val tagIcon = NiaIcons.Tag
NiaNavigationBar {
items.forEachIndexed { index, item ->
NiaNavigationBarItem(
icon = {
if (index == 3) {
Icon(imageVector = tagIcon, contentDescription = null)
} else {
Icon(
painter = painterResource(id = icons[index]),
contentDescription = item,
)
}
Icon(
imageVector = icons[index],
contentDescription = item,
)
},
selectedIcon = {
if (index == 3) {
Icon(imageVector = tagIcon, contentDescription = null)
} else {
Icon(
painter = painterResource(id = selectedIcons[index]),
contentDescription = item,
)
}
Icon(
imageVector = selectedIcons[index],
contentDescription = item,
)
},
label = { Text(item) },
selected = selectedItem == index,

@ -33,6 +33,7 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity
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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -66,9 +67,15 @@ class NavigationTest {
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)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use the primary activity to initialize the app normally.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<MainActivity>()
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.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
@ -61,9 +62,15 @@ class NavigationUiTest {
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)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(

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

@ -16,10 +16,8 @@
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.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.feature.bookmarks.R as bookmarksR
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.
*/
enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val iconTextId: Int,
val titleTextId: Int,
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved,
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests,
),

@ -15,6 +15,7 @@
*/
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
@ -32,8 +33,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
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.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
@ -46,11 +49,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
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.NiaNavigationRailItem
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.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
@ -129,6 +130,8 @@ fun NiaApp(
)
}
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
@ -139,7 +142,6 @@ fun NiaApp(
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
NiaBottomBar(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
@ -164,6 +166,7 @@ fun NiaApp(
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
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
@ -208,6 +217,7 @@ fun NiaApp(
@Composable
private fun NiaNavRail(
destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
@ -215,29 +225,24 @@ private fun NiaNavRail(
NiaNavigationRail(modifier = modifier) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
val hasUnread = destinationsWithUnreadResources.contains(destination)
NiaNavigationRailItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
val icon = if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null,
)
}
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
)
}
}
@ -261,48 +266,42 @@ private fun NiaBottomBar(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
val icon = if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null,
)
}
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) notificationDot() else Modifier,
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
)
}
}
}
@Composable
private fun notificationDot(): Modifier {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
return Modifier.drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
private fun Modifier.notificationDot(): Modifier =
composed {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
}
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any {

@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
import com.google.samples.apps.nowinandroid.NiaBuildType
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
goToBookmarksScreen()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp()
// Navigate to interests screen
goToInterestsScreen()

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.goToBookmarksScreen() {
device.findObject(By.text("Saved")).click()
@ -29,8 +28,3 @@ fun MacrobenchmarkScope.goToBookmarksScreen() {
val topAppBar = device.findObject(By.res("niaTopAppBar"))
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.test.internal.runner.junit4.AndroidJUnit4ClassRunner
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
@ -47,6 +48,7 @@ class ScrollForYouFeedBenchmark {
// Start the app
pressHome()
startActivityAndWait()
allowNotifications()
},
) {
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.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
@ -47,7 +48,7 @@ class TopicsScreenRecompositionBenchmark {
// Start the app
pressHome()
startActivityAndWait()
allowNotifications()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()

@ -16,20 +16,16 @@
package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.plugins.ExtensionAware
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.provideDelegate
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
/**
@ -92,9 +88,6 @@ private fun Project.configureKotlin() {
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf(
"-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.CommonExtension
import com.android.build.api.dsl.ProductFlavor
import org.gradle.api.Project
@Suppress("EnumEntryName")
enum class FlavorDimension {
@ -17,10 +16,10 @@ enum class FlavorDimension {
@Suppress("EnumEntryName")
enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType, )
prod(FlavorDimension.contentType)
}
fun Project.configureFlavors(
fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) {
@ -33,7 +32,7 @@ fun Project.configureFlavors(
flavorConfigurationBlock(this, it)
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.applicationIdSuffix != null) {
this.applicationIdSuffix = it.applicationIdSuffix
applicationIdSuffix = it.applicationIdSuffix
}
}
}

@ -34,7 +34,6 @@ data class AnalyticsEvent(
class Types {
companion object {
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 {
companion object {
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 {
api(project(":core:data"))
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.TopicDao
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.asFtsEntity
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.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
@ -45,7 +47,12 @@ class DefaultSearchContentsRepository @Inject constructor(
override suspend fun populateFtsData() {
withContext(ioDispatcher) {
newsResourceFtsDao.insertAll(
newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() },
newsResourceDao.getNewsResources(
useFilterTopicIds = false,
useFilterNewsIds = false,
)
.first()
.map(PopulatedNewsResource::asFtsEntity),
)
topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })
}

@ -21,6 +21,7 @@ import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.NetworkRequest.Builder
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
@ -44,36 +45,33 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
}
/**
* Sends the latest connectivity status to the underlying channel.
*/
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.
* 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].
*/
val callback = object : NetworkCallback() {
override fun onAvailable(network: Network) = update()
override fun onLost(network: Network) = update()
private val networks = mutableSetOf<Network>()
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) = update()
override fun onAvailable(network: Network) {
networks += network
channel.trySend(true)
}
override fun onLost(network: Network) {
networks -= network
channel.trySend(networks.isNotEmpty())
}
}
connectivityManager.registerNetworkCallback(
Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
callback,
)
val request = Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
update()
/**
* Sends the latest connectivity status to the underlying channel.
*/
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
@ -87,6 +85,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
activeNetwork
?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
else -> activeNetworkInfo?.isConnected
} ?: false
}

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

@ -65,10 +65,6 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(),
): 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
*/

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

@ -26,6 +26,7 @@ dependencies {
api(project(":core:datastore"))
api(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)
implementation(project(":core:common"))
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.UserPreferencesSerializer
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.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.junit.rules.TemporaryFolder
import javax.inject.Singleton
@ -43,13 +40,12 @@ object TestDataStoreModule {
@Provides
@Singleton
fun providesUserPreferencesDataStore(
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
@ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore(
// TODO: Provide an application-wide CoroutineScope in the DI graph
coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher),
coroutineScope = scope,
userPreferencesSerializer = userPreferencesSerializer,
)
}

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

@ -32,7 +32,6 @@ class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferenc
override suspend fun readFrom(input: InputStream): UserPreferences =
try {
// readFrom is already called on the data store background thread
@Suppress("BlockingMethodInNonBlockingContext")
UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
@ -40,7 +39,6 @@ class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferenc
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
// writeTo is already called on the data store background thread
@Suppress("BlockingMethodInNonBlockingContext")
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.network.Dispatcher
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.Provides
import dagger.hilt.InstallIn
@ -32,7 +33,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -44,11 +44,12 @@ object DataStoreModule {
fun providesUserPreferencesDataStore(
@ApplicationContext context: Context,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
@ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer,
): DataStore<UserPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
scope = CoroutineScope(ioDispatcher + SupervisorJob()),
scope = CoroutineScope(scope.coroutineContext + ioDispatcher),
migrations = listOf(
IntToStringIdsMigration,
),

@ -16,69 +16,45 @@
package com.google.samples.apps.nowinandroid.core.designsystem.icon
import androidx.annotation.DrawableRes
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.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.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.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.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
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.VolumeOff
import androidx.compose.material.icons.rounded.VolumeUp
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.
*/
object NiaIcons {
val AccountCircle = Icons.Outlined.AccountCircle
val Add = Icons.Rounded.Add
val ArrowBack = Icons.Rounded.ArrowBack
val ArrowDropDown = Icons.Default.ArrowDropDown
val ArrowDropUp = Icons.Default.ArrowDropUp
val Bookmark = R.drawable.ic_bookmark
val BookmarkBorder = R.drawable.ic_bookmark_border
val Bookmarks = R.drawable.ic_bookmarks
val BookmarksBorder = R.drawable.ic_bookmarks_border
val Bookmark = Icons.Rounded.Bookmark
val BookmarkBorder = Icons.Rounded.BookmarkBorder
val Bookmarks = Icons.Rounded.Bookmarks
val BookmarksBorder = Icons.Outlined.Bookmarks
val Check = Icons.Rounded.Check
val Close = Icons.Rounded.Close
val ExpandLess = Icons.Rounded.ExpandLess
val Fullscreen = Icons.Rounded.Fullscreen
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 Person = Icons.Rounded.Person
val PlayArrow = Icons.Rounded.PlayArrow
val Search = Icons.Rounded.Search
val Settings = Icons.Rounded.Settings
val ShortText = Icons.Rounded.ShortText
val Tag = Icons.Rounded.Tag
val Upcoming = R.drawable.ic_upcoming
val UpcomingBorder = R.drawable.ic_upcoming_border
val Upcoming = Icons.Rounded.Upcoming
val UpcomingBorder = Icons.Outlined.Upcoming
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 Blue80 = Color(0xFF5DD5FC)
internal val Blue90 = Color(0xFFB8EAFF)
internal val Blue95 = Color(0xFFDDF4FF)
internal val DarkGreen10 = Color(0xFF0D1F12)
internal val DarkGreen20 = Color(0xFF223526)
internal val DarkGreen30 = Color(0xFF394B3C)
@ -61,14 +60,12 @@ internal val Orange30 = Color(0xFF812800)
internal val Orange40 = Color(0xFFA23F16)
internal val Orange80 = Color(0xFFFFB59B)
internal val Orange90 = Color(0xFFFFDBCF)
internal val Orange95 = Color(0xFFFFEDE8)
internal val Purple10 = Color(0xFF36003C)
internal val Purple20 = Color(0xFF560A5D)
internal val Purple30 = Color(0xFF702776)
internal val Purple40 = Color(0xFF8B418F)
internal val Purple80 = Color(0xFFFFA9FE)
internal val Purple90 = Color(0xFFFFD6FA)
internal val Purple95 = Color(0xFFFFEBFA)
internal val PurpleGray30 = Color(0xFF4D444C)
internal val PurpleGray50 = Color(0xFF7F747C)
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.
-->
<resources>
<string name="news_notification_title">Now in Android</string>
<string name="news_notification_channel_name">News updates</string>
<string name="news_notification_channel_description">The latest updates on what\'s new in Android</string>
<string name="news_notification_group_summary">%1$d news updates</string>

@ -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
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.di.DispatchersModule
import dagger.Module
@ -35,4 +36,10 @@ object TestDispatchersModule {
@Provides
@Dispatcher(IO)
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.
*/

@ -183,13 +183,13 @@ fun BookmarkButton(
modifier = modifier,
icon = {
Icon(
painter = painterResource(NiaIcons.BookmarkBorder),
imageVector = NiaIcons.BookmarkBorder,
contentDescription = stringResource(R.string.bookmark),
)
},
checkedIcon = {
Icon(
painter = painterResource(NiaIcons.Bookmark),
imageVector = NiaIcons.Bookmark,
contentDescription = stringResource(R.string.unbookmark),
)
},
@ -250,14 +250,6 @@ fun NewsResourceMetaData(
)
}
@Composable
fun NewsResourceLink(
@Suppress("UNUSED_PARAMETER")
newsResource: NewsResource,
) {
TODO()
}
@Composable
fun NewsResourceShortDescription(
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,
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
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.
* `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>
<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>
</tr>
<tr>

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

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

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

@ -17,9 +17,6 @@
<resources>
<string name="saved">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_description">Updates you save will be stored here\nto read later</string>
<string name="bookmark_removed">Bookmark removed</string>

@ -14,8 +14,6 @@
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
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.onNodeWithText
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.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
@ -35,7 +36,11 @@ import org.junit.Rule
import org.junit.Test
class ForYouScreenTest {
@get:Rule
@get:Rule(order = 0)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val doneButtonMatcher by lazy {
@ -98,7 +103,7 @@ class ForYouScreenTest {
@Test
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
val testData = followableTopicTestData.map { it -> it.copy(isFollowed = false) }
val testData = followableTopicTestData.map { it.copy(isFollowed = false) }
composeTestRule.setContent {
BoxWithConstraints {

@ -68,6 +68,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -406,6 +407,9 @@ fun TopicIcon(
@Composable
@OptIn(ExperimentalPermissionsApi::class)
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
val notificationsPermissionState = rememberPermissionState(
android.Manifest.permission.POST_NOTIFICATIONS,

@ -21,13 +21,5 @@
<string name="navigate_up">Navigate up</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="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>

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

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

@ -20,7 +20,4 @@
<string name="empty_header">"No available data"</string>
<string name="card_follow_button_content_desc">Follow 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>

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

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

@ -18,7 +18,7 @@
<string name="top_app_bar_action_icon_description">Settings</string>
<string name="top_app_bar_navigation_icon_description">Search</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="licenses">Licenses</string>
<string name="brand_guidelines">Brand Guidelines</string>

@ -14,8 +14,6 @@
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id("nowinandroid.android.feature")
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.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading
@Composable
internal fun TopicRoute(
@ -107,7 +106,7 @@ internal fun TopicScreen(
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (topicUiState) {
Loading -> item {
TopicUiState.Loading -> item {
NiaLoadingWheel(
modifier = modifier,
contentDesc = stringResource(id = string.topic_loading),

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

@ -1,7 +1,7 @@
[versions]
accompanist = "0.28.0"
androidDesugarJdkLibs = "1.2.2"
androidGradlePlugin = "8.0.1"
androidGradlePlugin = "8.0.2"
androidxActivity = "1.7.0"
androidxAppCompat = "1.5.1"
androidxBrowser = "1.4.0"
@ -42,7 +42,7 @@ junit4 = "4.13.2"
kotlin = "1.8.20"
kotlinxCoroutines = "1.6.4"
kotlinxDatetime = "0.4.0"
kotlinxSerializationJson = "1.5.0"
kotlinxSerializationJson = "1.5.1"
ksp = "1.8.20-1.0.11"
lint = "30.3.1"
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
* the Now in Android design system module.
*/
@Suppress("UnstableApiUsage")
class DesignSystemDetector : Detector(), Detector.UastScanner {
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
* the Now in Android design system module.
*/
@Suppress("UnstableApiUsage")
class DesignSystemIssueRegistry : IssueRegistry() {
override val issues = listOf(DesignSystemDetector.ISSUE)

Loading…
Cancel
Save