Merge branch 'main' into replace-testharness

* main: (37 commits)
  Disable animations of instrumented tests (#1167)
  Bump gradle/wrapper-validation-action from 1 to 2
  Remove unused release variable. Fixes #1231
  Apply Composable Parameter Ordering Guidelines
  Remove kotlinx-coroutines-guava dependency from :sync:work
  Add explicit guava-android dependency for :sync:work
  Extract ScreenshotHelper to separate testing module
  Update AGP to 8.3.0
  Remove Insert function - Insert is only used in test. - Upsert do same thing as Insert.
  🤖 Updates baselines for Dependency Guard
  Bump the kotlin-ksp-compose group with 6 updates
  🤖 Updates baselines for Dependency Guard
  Bump hilt from 2.50 to 2.51
  Bump com.google.truth:truth from 1.1.5 to 1.4.2
  Move java to kotlin folder.
  Remove disk usage testing
  Figuring out what is using 66Gb in the runner
  Dpm
  Improve converting to kotlin timezone
  Use trySend multiple times
  ...

Change-Id: If3f564108d42675ba55ef242f0d06f04aff45c4a
pull/1230/head
Don Turner 4 months ago
commit 2d610b0775

@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v2
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
@ -37,7 +37,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/gradle-build-action@v3
- name: Check build-logic
run: ./gradlew check -p build-logic
@ -178,7 +178,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/gradle-build-action@v3
- name: Build projects before running emulator
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest

@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v2
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties

@ -122,9 +122,9 @@ tests against _all_ build variants which is both unecessary and will result in f
A screenshot test takes a screenshot of a screen or a UI component within the app, and compares it
with a previously recorded screenshot which is known to be rendered correctly.
For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemoDebug/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)
For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)
to verify that the navigation is displayed correctly on different screen sizes
([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemoDebug/screenshots)).
([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemo/screenshots)).
Now In Android uses [Roborazzi](https://github.com/takahirom/roborazzi) to run screenshot tests
of certain screens and UI components. When working with screenshot tests the following gradle tasks are useful:

@ -80,10 +80,10 @@ androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.dagger:dagger-lint-aar:2.51
com.google.dagger:dagger:2.51
com.google.dagger:hilt-android:2.51
com.google.dagger:hilt-core:2.51
com.google.guava:listenablefuture:1.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.6.0
@ -93,10 +93,10 @@ io.coil-kt:coil-compose-base:2.5.0
io.coil-kt:coil-compose:2.5.0
io.coil-kt:coil:2.5.0
javax.inject:javax.inject:1
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlin:kotlin-stdlib:1.9.22
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3

@ -45,7 +45,7 @@ android {
debug {
applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
}
val release = getByName("release") {
release {
isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@ -93,6 +93,7 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.profileinstaller)
@ -112,10 +113,12 @@ dependencies {
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)
testDemoImplementation(projects.core.screenshotTesting)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.hilt.android.testing)

@ -34,6 +34,7 @@ androidx.compose.material:material-ripple:1.6.1
androidx.compose.runtime:runtime-android:1.6.1
androidx.compose.runtime:runtime-saveable-android:1.6.1
androidx.compose.runtime:runtime-saveable:1.6.1
androidx.compose.runtime:runtime-tracing:1.0.0-beta01
androidx.compose.runtime:runtime:1.6.1
androidx.compose.ui:ui-android:1.6.1
androidx.compose.ui:ui-geometry-android:1.6.1
@ -109,6 +110,7 @@ androidx.sqlite:sqlite-framework:2.4.0
androidx.sqlite:sqlite:2.4.0
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing-perfetto:1.0.0
androidx.tracing:tracing:1.3.0-alpha02
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
@ -137,10 +139,10 @@ com.google.android.gms:play-services-oss-licenses:17.0.1
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.0.2
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.dagger:dagger-lint-aar:2.51
com.google.dagger:dagger:2.51
com.google.dagger:hilt-android:2.51
com.google.dagger:hilt-core:2.51
com.google.errorprone:error_prone_annotations:2.11.0
com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics-ktx:21.4.0
@ -188,10 +190,10 @@ io.github.aakira:napier-android:1.4.1
io.github.aakira:napier:1.4.1
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlin:kotlin-stdlib:1.9.22
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3

@ -19,15 +19,18 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
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
@ -82,6 +85,9 @@ class NavigationUiTest {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Before
fun setup() {
hiltRule.inject()
@ -94,13 +100,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(400.dp, 400.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -116,13 +116,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(610.dp, 400.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -138,13 +132,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 400.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -160,13 +148,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(400.dp, 500.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -182,13 +164,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(610.dp, 500.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -204,13 +180,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 500.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -226,13 +196,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(400.dp, 1000.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -248,13 +212,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(610.dp, 1000.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -270,13 +228,7 @@ class NavigationUiTest {
DeviceConfigurationOverride.ForcedSize(DpSize(900.dp, 1000.dp)),
) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
@ -284,4 +236,12 @@ class NavigationUiTest {
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Composable
private fun fakeAppState(maxWidth: Dp, maxHeight: Dp) = rememberNiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}

@ -34,10 +34,12 @@ import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNe
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
@ -59,6 +61,8 @@ class NiaAppStateTest {
// Create the test dependencies.
private val networkMonitor = TestNetworkMonitor()
private val timeZoneMonitor = TestTimeZoneMonitor()
private val userNewsResourceRepository =
CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository())
@ -78,6 +82,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -100,6 +105,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -118,6 +124,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -134,6 +141,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -150,6 +158,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -166,6 +175,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -177,6 +187,27 @@ class NiaAppStateTest {
)
}
@Test
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
val changedTz = TimeZone.of("Europe/Prague")
backgroundScope.launch { state.currentTimeZone.collect() }
timeZoneMonitor.setTimeZone(changedTz)
assertEquals(
changedTz,
state.currentTimeZone.value,
)
}
private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
}

@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats
@ -42,10 +43,13 @@ import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone
import com.google.samples.apps.nowinandroid.ui.NiaApp
import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
@ -67,6 +71,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
@ -126,17 +133,25 @@ class MainActivity : ComponentActivity() {
onDispose {}
}
CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
val appState = rememberNiaAppState(
windowSizeClass = calculateWindowSizeClass(this),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalAnalyticsHelper provides analyticsHelper,
LocalTimeZone provides currentTimeZone,
) {
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
NiaApp(
networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
userNewsResourceRepository = userNewsResourceRepository,
)
NiaApp(appState)
}
}
}

@ -39,7 +39,6 @@ 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -56,15 +55,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
@ -86,17 +82,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
ExperimentalComposeUiApi::class,
)
@Composable
fun NiaApp(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
modifier: Modifier = Modifier,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass,
userNewsResourceRepository = userNewsResourceRepository,
),
) {
fun NiaApp(appState: NiaAppState, modifier: Modifier = Modifier) {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
@ -197,13 +183,16 @@ fun NiaApp(
)
}
NiaNavHost(appState = appState, onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
})
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

@ -32,6 +32,7 @@ import androidx.navigation.navOptions
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
@ -50,12 +51,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
@Composable
fun rememberNiaAppState(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState {
@ -66,13 +69,15 @@ fun rememberNiaAppState(
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
timeZoneMonitor,
) {
NiaAppState(
navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
navController = navController,
coroutineScope = coroutineScope,
windowSizeClass = windowSizeClass,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
}
@ -80,10 +85,11 @@ fun rememberNiaAppState(
@Stable
class NiaAppState(
val navController: NavHostController,
val coroutineScope: CoroutineScope,
coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
) {
val currentDestination: NavDestination?
@Composable get() = navController
@ -127,12 +133,20 @@ class NiaAppState(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}.stateIn(
}
.stateIn(
coroutineScope,
SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet(),
)
val currentTimeZone = timeZoneMonitor.currentTimeZone
.stateIn(
coroutineScope,
SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(),
)
/**
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you

@ -20,9 +20,7 @@ import android.util.Log
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -39,6 +37,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
@ -96,6 +95,9 @@ class NiaAppScreenSizesScreenshotTests {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Inject
lateinit var userDataRepository: UserDataRepository
@ -148,14 +150,15 @@ class NiaAppScreenSizesScreenshotTests {
override = DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
) {
NiaTheme {
NiaApp(
val fakeAppState = rememberNiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(width, height),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
modifier = Modifier.testTag(appTestTag),
timeZoneMonitor = timeZoneMonitor,
)
NiaApp(fakeAppState)
}
}
}

@ -38,6 +38,9 @@ class ScrollForYouFeedBenchmark {
@Test
fun scrollFeedCompilationBaselineProfile() = scrollFeed(CompilationMode.Partial())
@Test
fun scrollFeedCompilationFull() = scrollFeed(CompilationMode.Full())
private fun scrollFeed(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(FrameTimingMetric()),

@ -39,6 +39,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
@Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {

@ -34,6 +34,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}

@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
testOptions.animationsDisabled = true
configureFlavors(this)
configureGradleManagedDevices(this)
// The resource prefix is derived from the module name,

@ -26,7 +26,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
* Configure Compose-specific options
*/
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
buildFeatures {
@ -41,6 +41,8 @@ internal fun Project.configureAndroidCompose(
val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom))
add("androidTestImplementation", platform(bom))
add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get())
add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
}
testOptions {
@ -53,7 +55,8 @@ internal fun Project.configureAndroidCompose(
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters()
freeCompilerArgs += buildComposeMetricsParameters()
freeCompilerArgs += stabilityConfiguration()
}
}
}
@ -68,7 +71,7 @@ private fun Project.buildComposeMetricsParameters(): List<String> {
val metricsFolder = buildDir.resolve("compose-metrics").resolve(relativePath)
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath,
)
}
@ -83,3 +86,8 @@ private fun Project.buildComposeMetricsParameters(): List<String> {
}
return metricParameters.toList()
}
private fun Project.stabilityConfiguration() = listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${project.rootDir.absolutePath}/compose_compiler_config.conf",
)

@ -25,7 +25,7 @@ import org.gradle.kotlin.dsl.invoke
* Configure project for Gradle managed devices
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd")
val pixel6 = DeviceConfig("Pixel 6", 31, "aosp")

@ -30,7 +30,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
* Configure base Kotlin with Android options
*/
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 34

@ -20,7 +20,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St
}
fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) {
commonExtension.apply {

@ -0,0 +1,6 @@
// This file contains classes (with possible wildcards) that the Compose Compiler will treat as stable.
// It allows us to define classes that our not part of our codebase without wrapping them in a stable class.
// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file
java.time.ZoneId
java.time.ZoneOffset

@ -0,0 +1,27 @@
/*
* Copyright 2024 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.data.test
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.TimeZone
import javax.inject.Inject
class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor {
override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of("Europe/Warsaw"))
}

@ -22,12 +22,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRep
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
@ -38,7 +39,7 @@ import dagger.hilt.testing.TestInstallIn
components = [SingletonComponent::class],
replaces = [DataModule::class],
)
interface TestDataModule {
internal interface TestDataModule {
@Binds
fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository,
@ -68,4 +69,7 @@ interface TestDataModule {
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor
@Binds
fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
@ -39,7 +39,7 @@ import javax.inject.Inject
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeNewsRepository @Inject constructor(
internal class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource,
) : NewsRepository {

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
@ -25,7 +25,7 @@ import javax.inject.Inject
/**
* Fake implementation of the [RecentSearchRepository]
*/
class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
internal class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
@ -25,7 +25,7 @@ import javax.inject.Inject
/**
* Fake implementation of the [SearchContentsRepository]
*/
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
internal class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
@ -36,7 +36,7 @@ import javax.inject.Inject
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeTopicsRepository @Inject constructor(
internal class FakeTopicsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource,
) : TopicsRepository {

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
@ -30,7 +30,7 @@ import javax.inject.Inject
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeUserDataRepository @Inject constructor(
internal class FakeUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository {

@ -28,6 +28,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneBroadcastMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -66,4 +68,7 @@ abstract class DataModule {
internal abstract fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor
@Binds
internal abstract fun binds(impl: TimeZoneBroadcastMonitor): TimeZoneMonitor
}

@ -0,0 +1,107 @@
/*
* Copyright 2024 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.data.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.tracing.trace
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.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinTimeZone
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* Utility for reporting current timezone the device has set.
* It always emits at least once with default setting and then for each TZ change.
*/
interface TimeZoneMonitor {
val currentTimeZone: Flow<TimeZone>
}
@Singleton
internal class TimeZoneBroadcastMonitor @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope appScope: CoroutineScope,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : TimeZoneMonitor {
override val currentTimeZone: SharedFlow<TimeZone> =
callbackFlow {
// Send the default time zone first.
trySend(TimeZone.currentSystemDefault())
// Registers BroadcastReceiver for the TimeZone changes
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_TIMEZONE_CHANGED) return
val zoneIdFromIntent = if (VERSION.SDK_INT < VERSION_CODES.R) {
null
} else {
// Starting Android R we also get the new TimeZone.
intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId ->
// We need to convert it from java.util.Timezone to java.time.ZoneId
val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS)
// Convert to kotlinx.datetime.TimeZone
zoneId.toKotlinTimeZone()
}
}
// If there isn't a zoneId in the intent, fallback to the systemDefault, which should also reflect the change
trySend(zoneIdFromIntent ?: TimeZone.currentSystemDefault())
}
}
trace("TimeZoneBroadcastReceiver.register") {
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}
// Send here again, because registering the Broadcast Receiver can take up to several milliseconds.
// This way, we can reduce the likelihood that a TZ change wouldn't be caught with the Broadcast Receiver.
trySend(TimeZone.currentSystemDefault())
awaitClose {
context.unregisterReceiver(receiver)
}
}
// We use to prevent multiple emissions of the same type, because we use trySend multiple times.
.distinctUntilChanged()
.conflate()
.flowOn(ioDispatcher)
// Sharing the callback to prevent multiple BroadcastReceivers being registered
.shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1)
}

@ -92,21 +92,6 @@ class TestNewsResourceDao : NewsResourceDao {
result.map { it.entity.id }
}
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>,
): List<Long> {
entitiesStateFlow.update { oldValues ->
// Old values come first so new values don't overwrite them
(oldValues + entities)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
// Assume no conflicts on insert
return entities.map { it.id.toLong() }
}
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.update { oldValues ->
// New values come first so they overwrite old values

@ -96,12 +96,6 @@ interface NewsResourceDao {
filterNewsIds: Set<String> = emptySet(),
): Flow<List<String>>
/**
* Inserts [entities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long>
/**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/

@ -35,17 +35,15 @@ dependencies {
api(libs.androidx.compose.material.iconsExtended)
api(libs.androidx.compose.material3)
api(libs.androidx.compose.runtime)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.compose.ui.util)
debugApi(libs.androidx.compose.ui.tooling)
implementation(libs.coil.kt.compose)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(projects.core.screenshotTesting)
testImplementation(projects.core.testing)
androidTestImplementation(libs.androidx.compose.ui.test)

@ -53,12 +53,12 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
fun RowScope.NiaNavigationBarItem(
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
selectedIcon: @Composable () -> Unit = icon,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true,
icon: @Composable () -> Unit,
selectedIcon: @Composable () -> Unit = icon,
label: @Composable (() -> Unit)? = null,
) {
NavigationBarItem(
selected = selected,
@ -117,12 +117,12 @@ fun NiaNavigationBar(
fun NiaNavigationRailItem(
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
selectedIcon: @Composable () -> Unit = icon,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true,
icon: @Composable () -> Unit,
selectedIcon: @Composable () -> Unit = icon,
label: @Composable (() -> Unit)? = null,
) {
NavigationRailItem(
selected = selected,

@ -75,10 +75,10 @@ private const val SCROLLBAR_INACTIVE_TO_DORMANT_TIME_IN_MS = 2_000L
*/
@Composable
fun ScrollableState.DraggableScrollbar(
modifier: Modifier = Modifier,
state: ScrollbarState,
orientation: Orientation,
onThumbMoved: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
Scrollbar(
@ -105,9 +105,9 @@ fun ScrollableState.DraggableScrollbar(
*/
@Composable
fun ScrollableState.DecorativeScrollbar(
modifier: Modifier = Modifier,
state: ScrollbarState,
orientation: Orientation,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
Scrollbar(

@ -195,13 +195,13 @@ internal fun Orientation.valueOf(intOffset: IntOffset) = when (this) {
*/
@Composable
fun Scrollbar(
modifier: Modifier = Modifier,
orientation: Orientation,
state: ScrollbarState,
minThumbSize: Dp = 40.dp,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource? = null,
thumb: @Composable () -> Unit,
minThumbSize: Dp = 40.dp,
onThumbMoved: ((Float) -> Unit)? = null,
thumb: @Composable () -> Unit,
) {
// Using Offset.Unspecified and Float.NaN instead of null
// to prevent unnecessary boxing of primitives

@ -0,0 +1,34 @@
/*
* 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
*
* 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.
*/
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.hilt)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.screenshottesting"
}
dependencies {
api(libs.roborazzi)
implementation(libs.androidx.compose.ui.test)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui.test)
implementation(libs.robolectric)
implementation(projects.core.common)
implementation(projects.core.designsystem)
}

@ -0,0 +1,17 @@
<?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.
-->
<manifest />

@ -26,7 +26,6 @@ android {
dependencies {
api(kotlin("test"))
api(libs.androidx.compose.ui.test)
api(libs.roborazzi)
api(projects.core.analytics)
api(projects.core.data)
api(projects.core.model)
@ -34,13 +33,10 @@ dependencies {
debugApi(libs.androidx.compose.ui.testManifest)
implementation(libs.androidx.compose.ui.test)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.test.rules)
implementation(libs.hilt.android.testing)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotlinx.datetime)
implementation(libs.robolectric.shadows)
implementation(projects.core.common)
implementation(projects.core.designsystem)
}

@ -0,0 +1,40 @@
/*
* 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
*
* 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.testing.util
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.TimeZone
class TestTimeZoneMonitor : TimeZoneMonitor {
private val timeZoneFlow = MutableStateFlow(defaultTimeZone)
override val currentTimeZone: Flow<TimeZone> = timeZoneFlow
/**
* A test-only API to set the from tests.
*/
fun setTimeZone(zoneId: TimeZone) {
timeZoneFlow.value = zoneId
}
companion object {
val defaultTimeZone: TimeZone = TimeZone.of("Europe/Warsaw")
}
}

@ -0,0 +1,26 @@
/*
* Copyright 2024 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.ui
import androidx.compose.runtime.compositionLocalOf
import kotlinx.datetime.TimeZone
/**
* TimeZone that can be provided with the TimeZoneMonitor.
* This way, it's not needed to pass every single composable the time zone to show in UI.
*/
val LocalTimeZone = compositionLocalOf { TimeZone.currentSystemDefault() }

@ -40,7 +40,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -49,7 +48,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -71,7 +69,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import java.time.ZoneId
import kotlinx.datetime.toJavaZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
@ -244,27 +242,11 @@ fun NotificationDot(
}
@Composable
fun dateFormatted(publishDate: Instant): String {
var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) }
val context = LocalContext.current
DisposableEffect(context) {
val receiver = TimeZoneBroadcastReceiver(
onTimeZoneChanged = { zoneId = ZoneId.systemDefault() },
)
receiver.register(context)
onDispose {
receiver.unregister(context)
}
}
return DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.withZone(zoneId)
.format(publishDate.toJavaInstant())
}
fun dateFormatted(publishDate: Instant): String = DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.withZone(LocalTimeZone.current.toJavaZoneId())
.format(publishDate.toJavaInstant())
@Composable
fun NewsResourceMetaData(

@ -1,50 +0,0 @@
/*
* 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
*
* 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.ui
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
class TimeZoneBroadcastReceiver(
val onTimeZoneChanged: () -> Unit,
) : BroadcastReceiver() {
private var registered = false
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) {
onTimeZoneChanged()
}
}
fun register(context: Context) {
if (!registered) {
val filter = IntentFilter()
filter.addAction(Intent.ACTION_TIMEZONE_CHANGED)
context.registerReceiver(this, filter)
registered = true
}
}
fun unregister(context: Context) {
if (registered) {
context.unregisterReceiver(this)
registered = false
}
}
}

@ -33,6 +33,7 @@ dependencies {
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
testImplementation(projects.core.testing)
testImplementation(projects.core.screenshotTesting)
testDemoImplementation(libs.roborazzi)
androidTestImplementation(projects.core.testing)

@ -100,10 +100,10 @@ import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@Composable
internal fun SearchRoute(
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
onInterestsClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
bookmarksViewModel: BookmarksViewModel = hiltViewModel(),
interestsViewModel: InterestsViewModel = hiltViewModel(),
searchViewModel: SearchViewModel = hiltViewModel(),
@ -114,36 +114,36 @@ internal fun SearchRoute(
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
SearchScreen(
modifier = modifier,
onBackClick = onBackClick,
onClearRecentSearches = searchViewModel::clearRecentSearches,
onFollowButtonClick = interestsViewModel::followTopic,
onInterestsClick = onInterestsClick,
searchQuery = searchQuery,
recentSearchesUiState = recentSearchQueriesUiState,
searchResultUiState = searchResultUiState,
onSearchQueryChanged = searchViewModel::onSearchQueryChanged,
onSearchTriggered = searchViewModel::onSearchTriggered,
onTopicClick = onTopicClick,
onClearRecentSearches = searchViewModel::clearRecentSearches,
onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved,
onNewsResourceViewed = { bookmarksViewModel.setNewsResourceViewed(it, true) },
recentSearchesUiState = recentSearchQueriesUiState,
searchQuery = searchQuery,
searchResultUiState = searchResultUiState,
onFollowButtonClick = interestsViewModel::followTopic,
onBackClick = onBackClick,
onInterestsClick = onInterestsClick,
onTopicClick = onTopicClick,
)
}
@Composable
internal fun SearchScreen(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
searchQuery: String = "",
recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading,
searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,
onSearchQueryChanged: (String) -> Unit = {},
onSearchTriggered: (String) -> Unit = {},
onClearRecentSearches: () -> Unit = {},
onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> },
onInterestsClick: () -> Unit = {},
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> },
onNewsResourceViewed: (String) -> Unit = {},
onSearchQueryChanged: (String) -> Unit = {},
onSearchTriggered: (String) -> Unit = {},
onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> },
onBackClick: () -> Unit = {},
onInterestsClick: () -> Unit = {},
onTopicClick: (String) -> Unit = {},
searchQuery: String = "",
recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading,
searchResultUiState: SearchResultUiState = SearchResultUiState.Loading,
) {
TrackScreenViewEvent(screenName = "Search")
Column(modifier = modifier) {
@ -177,8 +177,8 @@ internal fun SearchScreen(
is SearchResultUiState.Success -> {
if (searchResultUiState.isEmpty()) {
EmptySearchResultBody(
onInterestsClick = onInterestsClick,
searchQuery = searchQuery,
onInterestsClick = onInterestsClick,
)
if (recentSearchesUiState is RecentSearchQueriesUiState.Success) {
RecentSearchesBody(
@ -192,14 +192,14 @@ internal fun SearchScreen(
}
} else {
SearchResultBody(
searchQuery = searchQuery,
topics = searchResultUiState.topics,
onFollowButtonClick = onFollowButtonClick,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
newsResources = searchResultUiState.newsResources,
onSearchTriggered = onSearchTriggered,
onTopicClick = onTopicClick,
newsResources = searchResultUiState.newsResources,
searchQuery = searchQuery,
onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged,
onNewsResourceViewed = onNewsResourceViewed,
onFollowButtonClick = onFollowButtonClick,
)
}
}
@ -210,8 +210,8 @@ internal fun SearchScreen(
@Composable
fun EmptySearchResultBody(
onInterestsClick: () -> Unit,
searchQuery: String,
onInterestsClick: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@ -286,14 +286,14 @@ private fun SearchNotReadyBody() {
@Composable
private fun SearchResultBody(
searchQuery: String,
topics: List<FollowableTopic>,
newsResources: List<UserNewsResource>,
onFollowButtonClick: (String, Boolean) -> Unit,
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onSearchTriggered: (String) -> Unit,
onTopicClick: (String) -> Unit,
searchQuery: String = "",
onNewsResourcesCheckedChanged: (String, Boolean) -> Unit,
onNewsResourceViewed: (String) -> Unit,
onFollowButtonClick: (String, Boolean) -> Unit,
) {
val state = rememberLazyStaggeredGridState()
Box(
@ -392,9 +392,9 @@ private fun SearchResultBody(
@Composable
private fun RecentSearchesBody(
recentSearchQueries: List<String>,
onClearRecentSearches: () -> Unit,
onRecentSearchClicked: (String) -> Unit,
recentSearchQueries: List<String>,
) {
Column {
Row(
@ -444,11 +444,11 @@ private fun RecentSearchesBody(
@Composable
private fun SearchToolbar(
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
searchQuery: String,
onSearchQueryChanged: (String) -> Unit,
searchQuery: String = "",
onSearchTriggered: (String) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -473,8 +473,8 @@ private fun SearchToolbar(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SearchTextField(
onSearchQueryChanged: (String) -> Unit,
searchQuery: String,
onSearchQueryChanged: (String) -> Unit,
onSearchTriggered: (String) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
@ -556,6 +556,7 @@ private fun SearchTextField(
private fun SearchToolbarPreview() {
NiaTheme {
SearchToolbar(
searchQuery = "",
onBackClick = {},
onSearchQueryChanged = {},
onSearchTriggered = {},

@ -2,14 +2,14 @@
accompanist = "0.32.0"
androidDesugarJdkLibs = "2.0.4"
# AGP and tools should be updated together
androidGradlePlugin = "8.2.0"
androidTools = "31.2.0"
androidGradlePlugin = "8.3.0"
androidTools = "31.3.0"
androidxActivity = "1.8.0"
androidxAppCompat = "1.6.1"
androidxBrowser = "1.6.0"
androidxComposeBom = "2024.02.00"
androidxComposeCompiler = "1.5.8"
androidxComposeUiTest = "1.7.0-alpha03"
androidxComposeCompiler = "1.5.7"
androidxComposeRuntimeTracing = "1.0.0-beta01"
androidxCore = "1.12.0"
androidxCoreSplashscreen = "1.0.1"
@ -37,15 +37,15 @@ firebasePerfPlugin = "1.4.2"
gmsPlugin = "4.4.0"
googleOss = "17.0.1"
googleOssPlugin = "0.10.6"
hilt = "2.50"
hilt = "2.51"
hiltExt = "1.1.0"
jacoco = "0.8.7"
junit4 = "4.13.2"
kotlin = "1.9.21"
kotlin = "1.9.22"
kotlinxCoroutines = "1.7.3"
kotlinxDatetime = "0.5.0"
kotlinxSerializationJson = "1.6.0"
ksp = "1.9.21-1.0.16"
ksp = "1.9.22-1.0.18"
okhttp = "4.12.0"
protobuf = "3.25.2"
protobufPlugin = "0.9.4"
@ -55,7 +55,7 @@ robolectric = "4.11.1"
roborazzi = "1.7.0"
room = "2.6.1"
secrets = "2.0.1"
truth = "1.1.5"
truth = "1.4.2"
turbine = "1.0.0"
[libraries]
@ -72,6 +72,7 @@ androidx-compose-material-iconsExtended = { group = "androidx.compose.material",
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" }
androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxComposeUiTest" }
androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
@ -127,7 +128,6 @@ protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
robolectric-shadows = { group = "org.robolectric", name = "shadows-framework", version.ref = "robolectric" }
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }

@ -36,6 +36,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":app")
include(":app-nia-catalog")
include(":benchmarks")
include(":core:analytics")
include(":core:common")
include(":core:data")
include(":core:data-test")
@ -47,10 +48,10 @@ include(":core:designsystem")
include(":core:domain")
include(":core:model")
include(":core:network")
include(":core:ui")
include(":core:testing")
include(":core:analytics")
include(":core:notifications")
include(":core:screenshot-testing")
include(":core:testing")
include(":core:ui")
include(":feature:foryou")
include(":feature:interests")

@ -40,6 +40,5 @@ dependencies {
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlinx.coroutines.guava)
androidTestImplementation(projects.core.testing)
}

@ -35,7 +35,7 @@ cp "${GIT_ROOT}/tools/pre-push" "${GIT_DIR}/hooks/pre-push" \
cat <<-EOF
Checking the following settings helps avoid miscellaneous issues:
* Settings -> Editor -> General -> Remove trailing spaces on: Modified lines
* Settings -> Editor -> General -> Ensure every file ends with a line break
* Settings -> Editor -> General -> Ensure every saved file ends with a line break
* Settings -> Editor -> General -> Auto Import -> Optimize imports on the fly (for both Kotlin\
and Java)
EOF

Loading…
Cancel
Save