diff --git a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt index 182ff5451..2c921f091 100644 --- a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt +++ b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 520baa134..d674f4bec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt index e7a4ac804..610ed087b 100644 --- a/app/dependencies/prodReleaseRuntimeClasspath.txt +++ b/app/dependencies/prodReleaseRuntimeClasspath.txt @@ -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 @@ -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 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..732e527bb 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,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)) } 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..ad95c297f 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 @@ -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) } } } 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..d423adfbf 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 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 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 f7345f04f..83ca1bb3d 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.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity @@ -94,6 +95,9 @@ class NiaAppScreenSizesScreenshotTests { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var timeZoneMonitor: TimeZoneMonitor + @Inject lateinit var userDataRepository: UserDataRepository @@ -142,13 +146,15 @@ class NiaAppScreenSizesScreenshotTests { TestHarness(size = DpSize(width, height)) { BoxWithConstraints { NiaTheme { - 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..c74d79307 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()), 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..5a21ae337 --- /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 kotlinx.datetime.TimeZone +import javax.inject.Inject + +class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor { + override val currentTimeZone: Flow = flowOf(TimeZone.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..031bc9388 --- /dev/null +++ b/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/util/TimeZoneMonitor.kt @@ -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 +} + +@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 = + 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) +} 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..cc71ab2ca --- /dev/null +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/TestTimeZoneMonitor.kt @@ -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 = 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") + } +} 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..2d9948488 --- /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 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() } 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 0500bb455..e3fd29e9a 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,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( 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 c26e134ca..85064eb6d 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 = "2024.02.00" -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.25.2" protobufPlugin = "0.9.4" @@ -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" } androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }