From cb00d2c8cbd9c783f220bc99ad1a7e3d72e24007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Mon, 29 Jan 2024 22:58:47 +0100 Subject: [PATCH] Add TimeZoneMonitor to prevent multiple TimeZoneBroadcastReceivers This way, we can save ~1ms per composed item on screen. Change-Id: Ib9ada3cea53304fca4fb2b36c48c175845bc683d --- .../apps/nowinandroid/ui/NavigationUiTest.kt | 86 +++++------------ .../apps/nowinandroid/ui/NiaAppStateTest.kt | 31 ++++++ .../samples/apps/nowinandroid/MainActivity.kt | 27 ++++-- .../samples/apps/nowinandroid/ui/NiaApp.kt | 31 +++--- .../apps/nowinandroid/ui/NiaAppState.kt | 28 ++++-- .../ui/NiaAppScreenSizesScreenshotTests.kt | 8 +- .../foryou/ScrollForYouFeedBenchmark.kt | 4 + .../apps/nowinandroid/AndroidCompose.kt | 10 +- compose_compiler_config.conf | 6 ++ .../data/test/DefaultZoneIdTimeZoneMonitor.kt | 27 ++++++ .../core/data/test/TestDataModule.kt | 4 + .../nowinandroid/core/data/di/DataModule.kt | 5 + .../core/data/util/TimeZoneMonitor.kt | 94 +++++++++++++++++++ .../core/testing/util/TestTimeZoneMonitor.kt | 41 ++++++++ .../nowinandroid/core/ui/LocalTimeZone.kt | 26 +++++ .../nowinandroid/core/ui/NewsResourceCard.kt | 29 +----- .../core/ui/TimeZoneBroadcastReceiver.kt | 50 ---------- gradle/libs.versions.toml | 6 +- 18 files changed, 337 insertions(+), 176 deletions(-) create mode 100644 compose_compiler_config.conf create mode 100644 core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt create mode 100644 core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt create mode 100644 core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt create mode 100644 core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt delete mode 100644 core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt index d92390918..5d2e12b5c 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt @@ -19,14 +19,17 @@ 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.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.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.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 @@ -81,6 +84,9 @@ class NavigationUiTest { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + @Before fun setup() { hiltRule.inject() @@ -91,13 +97,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(400.dp, 400.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -111,13 +111,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(610.dp, 400.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -131,13 +125,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(900.dp, 400.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -151,13 +139,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(400.dp, 500.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -171,13 +153,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(610.dp, 500.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -191,13 +167,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(900.dp, 500.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -211,13 +181,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(400.dp, 1000.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -231,13 +195,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(610.dp, 1000.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -251,13 +209,7 @@ class NavigationUiTest { composeTestRule.setContent { TestHarness(size = DpSize(900.dp, 1000.dp)) { BoxWithConstraints { - NiaApp( - windowSizeClass = WindowSizeClass.calculateFromSize( - DpSize(maxWidth, maxHeight), - ), - networkMonitor = networkMonitor, - userNewsResourceRepository = userNewsResourceRepository, - ) + NiaApp(fakeAppState(maxWidth, maxHeight)) } } } @@ -265,4 +217,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, + ) } diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 1560a74eb..18afc6a09 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -34,12 +34,14 @@ 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 org.junit.Rule import org.junit.Test +import java.time.ZoneId import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -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 = ZoneId.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)) } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index 6ce134ef4..779b6edbe 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -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.collectAsState() + + 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) } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 2beda99ea..b2eabe2ed 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -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 @@ -62,8 +61,6 @@ 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 @@ -85,16 +82,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR ExperimentalComposeUiApi::class, ) @Composable -fun NiaApp( - windowSizeClass: WindowSizeClass, - networkMonitor: NetworkMonitor, - userNewsResourceRepository: UserNewsResourceRepository, - appState: NiaAppState = rememberNiaAppState( - networkMonitor = networkMonitor, - windowSizeClass = windowSizeClass, - userNewsResourceRepository = userNewsResourceRepository, - ), -) { +fun NiaApp(appState: NiaAppState) { val shouldShowGradientBackground = appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU var showSettingsDialog by rememberSaveable { mutableStateOf(false) } @@ -195,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 diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 7b66efb06..ffd0c16ec 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -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 java.time.ZoneId @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, + private val 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.currentZoneId + .stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5_000), + ZoneId.systemDefault(), + ) + /** * 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 diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index dcbc1e5c0..f3514b02f 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -37,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.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.BindValue @@ -93,6 +94,9 @@ class NiaAppScreenSizesScreenshotTests { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + @Inject lateinit var userDataRepository: UserDataRepository @@ -140,13 +144,15 @@ class NiaAppScreenSizesScreenshotTests { ) { TestHarness(size = DpSize(width, height)) { BoxWithConstraints { - NiaApp( + val fakeAppState = rememberNiaAppState( windowSizeClass = WindowSizeClass.calculateFromSize( DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, + timeZoneMonitor = timeZoneMonitor, ) + NiaApp(fakeAppState) } } } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt index 6d0091cd4..24a50dd79 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt @@ -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()), @@ -55,3 +58,4 @@ class ScrollForYouFeedBenchmark { forYouScrollFeedDownUp() } } + diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 039987cac..72d37db1b 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -53,7 +53,8 @@ internal fun Project.configureAndroidCompose( tasks.withType().configureEach { kotlinOptions { - freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() + freeCompilerArgs += buildComposeMetricsParameters() + freeCompilerArgs += stabilityConfiguration() } } } @@ -68,7 +69,7 @@ private fun Project.buildComposeMetricsParameters(): List { 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 +84,8 @@ private fun Project.buildComposeMetricsParameters(): List { } return metricParameters.toList() } + +private fun Project.stabilityConfiguration() = listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${project.rootDir.absolutePath}/compose_compiler_config.conf", +) diff --git a/compose_compiler_config.conf b/compose_compiler_config.conf new file mode 100644 index 000000000..2341256f4 --- /dev/null +++ b/compose_compiler_config.conf @@ -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 diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt new file mode 100644 index 000000000..ee8fe97a3 --- /dev/null +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/DefaultZoneIdTimeZoneMonitor.kt @@ -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 java.time.ZoneId +import javax.inject.Inject + +class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor { + override val currentZoneId: Flow = flowOf(ZoneId.of("Europe/Warsaw")) +} diff --git a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index 2ec2bcf9c..5bd2b8a43 100644 --- a/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -28,6 +28,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearch 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.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import dagger.Binds import dagger.Module import dagger.hilt.components.SingletonComponent @@ -68,4 +69,7 @@ interface TestDataModule { fun bindsNetworkMonitor( networkMonitor: AlwaysOnlineNetworkMonitor, ): NetworkMonitor + + @Binds + fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index e135d7f58..fa4bde8b8 100644 --- a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -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 } diff --git a/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt new file mode 100644 index 000000000..33b37934a --- /dev/null +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt @@ -0,0 +1,94 @@ +/* + * 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.core.os.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.flowOn +import kotlinx.coroutines.flow.shareIn +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 currentZoneId: Flow +} + +@Singleton +internal class TimeZoneBroadcastMonitor @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope appScope: CoroutineScope, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : TimeZoneMonitor { + + override val currentZoneId: SharedFlow = + callbackFlow { + // Send the default time zone first. + trySend(ZoneId.systemDefault()) + + // 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 { zoneId -> + // We need to convert it from java.util.Timezone to java.time.ZoneId + ZoneId.of(zoneId, ZoneId.SHORT_IDS) + } + } + + // If there isn't a zoneId in the intent, fallback to the systemDefault, which should also reflect the change + trySend(zoneIdFromIntent ?: ZoneId.systemDefault()) + } + } + + trace("TimeZoneBroadcastReceiver.register") { + context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)) + } + + awaitClose { + context.unregisterReceiver(receiver) + } + } + .flowOn(ioDispatcher) + // Sharing the callback to prevent multiple BroadcastReceivers being registered + .shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1) +} diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt new file mode 100644 index 000000000..8eb438fb1 --- /dev/null +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt @@ -0,0 +1,41 @@ +/* + * 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 java.time.ZoneId + +class TestTimeZoneMonitor : TimeZoneMonitor { + + private val timeZoneFlow = MutableStateFlow(defaultTimeZone) + + override val currentZoneId: Flow = timeZoneFlow + + /** + * A test-only API to set the from tests. + */ + fun setTimeZone(zoneId: ZoneId) { + timeZoneFlow.value = zoneId + } + + companion object { + val defaultTimeZone: ZoneId = ZoneId.of("Europe/Warsaw") + } +} + diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt new file mode 100644 index 000000000..cda5040bd --- /dev/null +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/LocalTimeZone.kt @@ -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 java.time.ZoneId + +/** + * 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 { ZoneId.systemDefault() } diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index 9eca6b141..c922d9687 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -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,6 @@ 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 java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale @@ -244,27 +241,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) + .format(publishDate.toJavaInstant()) @Composable fun NewsResourceMetaData( diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt deleted file mode 100644 index f7ae813c4..000000000 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/TimeZoneBroadcastReceiver.kt +++ /dev/null @@ -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 - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ef4cc225..5bcdaf41c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ androidxActivity = "1.8.0" androidxAppCompat = "1.6.1" androidxBrowser = "1.6.0" androidxComposeBom = "2023.10.01" -androidxComposeCompiler = "1.5.7" +androidxComposeCompiler = "1.5.8" androidxComposeRuntimeTracing = "1.0.0-beta01" androidxCore = "1.12.0" androidxCoreSplashscreen = "1.0.1" @@ -40,11 +40,11 @@ hilt = "2.50" 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.16" okhttp = "4.12.0" protobuf = "3.24.4" protobufPlugin = "0.9.4"