diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml deleted file mode 100644 index e10c49f9e..000000000 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ /dev/null @@ -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: Setup Android SDK - uses: android-actions/setup-android@v2 - - - 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' diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 0389dcf56..f28a9a89c 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -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: Setup Android SDK + uses: android-actions/setup-android@v2 + + - 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' diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 000000000..c91b32cd9 Binary files /dev/null and b/.idea/icon.png differ diff --git a/.idea/icon_dark.png b/.idea/icon_dark.png new file mode 100644 index 000000000..cc8a9c1b0 Binary files /dev/null and b/.idea/icon_dark.png differ diff --git a/README.md b/README.md index cd699caa4..9aca22cbd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt index 54e4264fa..2624262ad 100644 --- a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt +++ b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt @@ -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, diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 5aa3ab02e..036a2955c 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -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() private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt index cd4b40a50..d92390918 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt @@ -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() val userNewsResourceRepository = CompositeUserNewsResourceRepository( diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index e43dfaba7..1d600b53d 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -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) }, diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index 396ab8b7b..8dbd0fcb6 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -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, ), diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 6f6ab0603..aa85afebd 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -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, + destinationsWithUnreadResources: Set, 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 { diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 320d66647..48a6687e4 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -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 diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.kt new file mode 100644 index 000000000..48472e523 --- /dev/null +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.kt @@ -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) + } +} diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt index 3dfafd647..5abf7db4a 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt @@ -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() diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt index 3dce5b313..f66fa27a2 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt @@ -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) -} diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt index f8945a31c..3008fdc0d 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt @@ -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() diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt new file mode 100644 index 000000000..b43d3a84b --- /dev/null +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt @@ -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() + } + } +} diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt index 24bd233ea..0030386b7 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt @@ -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() diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 43edd53ec..edffeeda7 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -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", ) } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index dec592542..ef55024e2 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -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 } } } diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt index 3e0650eed..97ae76b56 100644 --- a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt @@ -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" } } } diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt index b73c63568..c265394a8 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt @@ -17,7 +17,7 @@ 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.IO +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,15 +25,20 @@ 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 - @Dispatcher(IO) - fun providesIOCoroutineScope( - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher) + @ApplicationScope + fun providesCoroutineScope( + @Dispatcher(Default) dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) } diff --git a/core/data-test/build.gradle.kts b/core/data-test/build.gradle.kts index f50e7b4b8..dfc224e19 100644 --- a/core/data-test/build.gradle.kts +++ b/core/data-test/build.gradle.kts @@ -25,4 +25,5 @@ android { dependencies { api(project(":core:data")) implementation(project(":core:testing")) + implementation(project(":core:common")) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt index 40b170cbe..dc3caa143 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt @@ -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() }) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt index b0bf9d820..c88125be8 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/ConnectivityManagerNetworkMonitor.kt @@ -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() - 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 } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index 09af77213..d5d8932e7 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -67,8 +67,6 @@ class TestNewsResourceDao : NewsResourceDao { result } - override suspend fun getOneOffNewsResources(): List = emptyList() - override suspend fun insertOrIgnoreNewsResources( entities: List, ): List { diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index b5949c6d2..a05507a8b 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -65,10 +65,6 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> - @Transaction - @Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC") - suspend fun getOneOffNewsResources(): List - /** * Inserts [entities] into the db if they don't exist, and ignores those that do */ diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt index 0ef9333c1..0ba625024 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt @@ -36,9 +36,3 @@ data class NewsResourceFtsEntity( @ColumnInfo(name = "content") val content: String, ) - -fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity( - newsResourceId = id, - title = title, - content = content, -) diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index c7c423c25..193c49da7 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -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")) } diff --git a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt index cb7e38db7..b86003e83 100644 --- a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt +++ b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt @@ -21,8 +21,7 @@ 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 @@ -41,12 +40,12 @@ object TestDataStoreModule { @Provides @Singleton fun providesUserPreferencesDataStore( - @Dispatcher(IO) ioScope: CoroutineScope, + @ApplicationScope scope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer, tmpFolder: TemporaryFolder, ): DataStore = tmpFolder.testUserPreferencesDataStore( - coroutineScope = ioScope, + coroutineScope = scope, userPreferencesSerializer = userPreferencesSerializer, ) } diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index 6d585ebd4..6e2be2808 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -143,13 +143,13 @@ class NiaPreferencesDataSource @Inject constructor( } suspend fun setNewsResourcesViewed(newsResourceIds: List, 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) } } } diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt index 6d1a4ab8b..40c1e210f 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt @@ -32,7 +32,6 @@ class UserPreferencesSerializer @Inject constructor() : Serializer = DataStoreFactory.create( serializer = userPreferencesSerializer, - scope = ioScope, + scope = CoroutineScope(scope.coroutineContext + ioDispatcher), migrations = listOf( IntToStringIdsMigration, ), diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt index 5646f088a..8db20689f 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt @@ -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() } diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt index ec4fa76b7..103457b08 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt @@ -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) diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark.xml b/core/designsystem/src/main/res/drawable/ic_bookmark.xml deleted file mode 100644 index 29b7e40a7..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmark.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark_border.xml b/core/designsystem/src/main/res/drawable/ic_bookmark_border.xml deleted file mode 100644 index 1d4b4aca9..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmark_border.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_bookmarks.xml b/core/designsystem/src/main/res/drawable/ic_bookmarks.xml deleted file mode 100644 index ed6e84f81..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmarks.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml b/core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml deleted file mode 100644 index 64f0b5159..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_menu_book.xml b/core/designsystem/src/main/res/drawable/ic_menu_book.xml deleted file mode 100644 index e81276888..000000000 --- a/core/designsystem/src/main/res/drawable/ic_menu_book.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_menu_book_border.xml b/core/designsystem/src/main/res/drawable/ic_menu_book_border.xml deleted file mode 100644 index 04ec651f6..000000000 --- a/core/designsystem/src/main/res/drawable/ic_menu_book_border.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_upcoming.xml b/core/designsystem/src/main/res/drawable/ic_upcoming.xml deleted file mode 100644 index a05017e74..000000000 --- a/core/designsystem/src/main/res/drawable/ic_upcoming.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_upcoming_border.xml b/core/designsystem/src/main/res/drawable/ic_upcoming_border.xml deleted file mode 100644 index 5f3151232..000000000 --- a/core/designsystem/src/main/res/drawable/ic_upcoming_border.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml index a3f8a4e61..5bb37b23a 100644 --- a/core/notifications/src/main/res/values/strings.xml +++ b/core/notifications/src/main/res/values/strings.xml @@ -15,7 +15,6 @@ limitations under the License. --> - Now in Android News updates The latest updates on what\'s new in Android %1$d news updates diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/rules/GrantPostNotificationsPermissionRule.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/rules/GrantPostNotificationsPermissionRule.kt new file mode 100644 index 000000000..512399d85 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/rules/GrantPostNotificationsPermissionRule.kt @@ -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() diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt index a5eb506ae..f2134105a 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt @@ -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 } diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index 66ac80868..9d1650c98 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -112,22 +112,6 @@ class TestUserDataRepository : UserDataRepository { } } - /** - * A test-only API to allow setting/unsetting of bookmarks. - * - */ - fun setNewsResourceBookmarks(newsResourceIds: Set) { - currentUserData.let { current -> - _userData.tryEmit(current.copy(bookmarkedNewsResources = newsResourceIds)) - } - } - - /** - * A test-only API to allow querying the current followed topics. - */ - fun getCurrentFollowedTopics(): Set? = - _userData.replayCache.firstOrNull()?.followedTopics - /** * A test-only API to allow setting of user data directly. */ diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index a6a7aafc9..009fb1249 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -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, diff --git a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index 680c6dcf7..6e432f2ab 100644 --- a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -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 = {}, diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 25412e851..0f15e29b0 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -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") } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt index eeb7f1576..ebcde4ab1 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt @@ -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) } } diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/src/main/res/values/strings.xml index 2dd36659e..875a90a0b 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/src/main/res/values/strings.xml @@ -17,9 +17,6 @@ Saved Loading saved… - Saved - Search - Menu No saved updates Updates you save will be stored here\nto read later Bookmark removed diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index 6cd5216d6..bd633e3d2 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -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") diff --git a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index b138cba06..7431555ba 100644 --- a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -16,9 +16,6 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import android.Manifest -import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.TIRAMISU import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.ui.test.assertHasClickAction @@ -31,8 +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 androidx.test.rule.GrantPermissionRule -import androidx.test.rule.GrantPermissionRule.grant +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 @@ -41,15 +37,10 @@ import org.junit.Test class ForYouScreenTest { - @get:Rule - val permissionTestRule: GrantPermissionRule = - if (SDK_INT >= TIRAMISU) { - grant(Manifest.permission.POST_NOTIFICATIONS) - } else { - grant() - } + @get:Rule(order = 0) + val postNotificationsPermission = GrantPostNotificationsPermissionRule() - @get:Rule + @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() private val doneButtonMatcher by lazy { @@ -112,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 { diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index ebc0a6fe9..70cc7e541 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -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, diff --git a/feature/foryou/src/main/res/values/strings.xml b/feature/foryou/src/main/res/values/strings.xml index 1880ab953..5a33bc9c8 100644 --- a/feature/foryou/src/main/res/values/strings.xml +++ b/feature/foryou/src/main/res/values/strings.xml @@ -21,13 +21,5 @@ Navigate up What are you interested in? Updates from topics you follow will appear here. Follow some things to get started. - Now in Android - Search - - - You are following - You are not following - Follow - Unfollow diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index e99cfb74d..6a2ea4a02 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -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() diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts index 12b3074e4..5c4b0360a 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/interests/build.gradle.kts @@ -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") diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 68deb933e..384cb1deb 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -20,7 +20,4 @@ "No available data" Follow interest Unfollow interest - Interests - Menu - Search diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 3229c350f..ef367d612 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -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") diff --git a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt index bdcb01ed2..d8411113d 100644 --- a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt @@ -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, diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index cbd4df8ed..ad56f6b08 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Settings Search Settings - Loading... + Loading… Privacy policy Licenses Brand Guidelines diff --git a/feature/topic/build.gradle.kts b/feature/topic/build.gradle.kts index 6bacd8343..ecb0630ce 100644 --- a/feature/topic/build.gradle.kts +++ b/feature/topic/build.gradle.kts @@ -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") diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index fd408f9cf..b987a2752 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -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), diff --git a/feature/topic/src/main/res/values/strings.xml b/feature/topic/src/main/res/values/strings.xml index 21e3ec246..284f2f7b2 100644 --- a/feature/topic/src/main/res/values/strings.xml +++ b/feature/topic/src/main/res/values/strings.xml @@ -15,6 +15,5 @@ limitations under the License. --> - Topic Loading topic diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0be000440..fd37438cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt index 6f4bca0df..4c9d55764 100644 --- a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt +++ b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt @@ -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> { diff --git a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt index d951151bb..bb7e971e3 100644 --- a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt +++ b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt @@ -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)