diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4fae0d3f..f0dc999b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,6 +24,7 @@ plugins { id("jacoco") id("dagger.hilt.android.plugin") id("nowinandroid.spotless") + id("nowinandroid.firebase-perf") } android { diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index c4dea1f2b..19ec44ad2 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -22,12 +22,21 @@ import androidx.activity.compose.setContent import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.view.WindowCompat +import androidx.metrics.performance.JankStats import com.google.samples.apps.nowinandroid.ui.NiaApp import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint class MainActivity : ComponentActivity() { + + /** + * Lazily inject [JankStats], which is used to track jank throughout the app. + */ + @Inject + lateinit var lazyStats: dagger.Lazy + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -35,6 +44,18 @@ class MainActivity : ComponentActivity() { // including IME animations WindowCompat.setDecorFitsSystemWindows(window, false) - setContent { NiaApp(calculateWindowSizeClass(this)) } + setContent { + NiaApp(calculateWindowSizeClass(this)) + } + } + + override fun onResume() { + super.onResume() + lazyStats.get().isTrackingEnabled = true + } + + override fun onPause() { + super.onPause() + lazyStats.get().isTrackingEnabled = false } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt new file mode 100644 index 000000000..6d11a9f68 --- /dev/null +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/di/JankStatsModule.kt @@ -0,0 +1,63 @@ +/* + * 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.di + +import android.app.Activity +import android.util.Log +import android.view.Window +import androidx.metrics.performance.JankStats +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import java.util.concurrent.Executor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor + +@Module +@InstallIn(ActivityComponent::class) +object JankStatsModule { + @Provides + fun providesOnFrameListener(): JankStats.OnFrameListener { + return JankStats.OnFrameListener { frameData -> + // Make sure to only log janky frames. + if (frameData.isJank) { + // We're currently logging this but would better report it to a backend. + Log.v("NiA Jank", frameData.toString()) + } + } + } + + @Provides + fun providesWindow(activity: Activity): Window { + return activity.window + } + + @Provides + fun providesDefaultExecutor(): Executor { + return Dispatchers.Default.asExecutor() + } + + @Provides + fun providesJankStats( + window: Window, + executor: Executor, + frameListener: JankStats.OnFrameListener + ): JankStats { + return JankStats.createAndTrack(window, executor, frameListener) + } +} diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt index 6023982e8..54d7bd402 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaTopLevelNavigation.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.navigation import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController +import androidx.tracing.trace import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon @@ -39,18 +40,20 @@ import com.google.samples.apps.nowinandroid.feature.interests.navigation.Interes class NiaTopLevelNavigation(private val navController: NavHostController) { fun navigateTo(destination: TopLevelDestination) { - navController.navigate(destination.route) { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + trace("Navigation: $destination") { + navController.navigate(destination.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true } } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index d5ca8bf5e..28f02a410 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.compose.currentBackStackEntryAsState @@ -58,6 +59,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaTopLevelNavigation import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_DESTINATIONS @@ -126,6 +128,17 @@ fun NiaApp(windowSizeClass: WindowSizeClass) { } } } + JankMetricDisposableEffect(navController) { metricsHolder -> + val listener = NavController.OnDestinationChangedListener { _, destination, _ -> + metricsHolder.state?.addState("Navigation", destination.route.toString()) + } + + navController.addOnDestinationChangedListener(listener) + + onDispose { + navController.removeOnDestinationChangedListener(listener) + } + } } } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 9a5baaa41..a7841103f 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -69,5 +69,9 @@ gradlePlugin { id = "nowinandroid.spotless" implementationClass = "SpotlessConventionPlugin" } + register("firebase-perf") { + id = "nowinandroid.firebase-perf" + implementationClass = "FirebasePerfConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt b/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt new file mode 100644 index 000000000..48f750678 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt @@ -0,0 +1,29 @@ +/* + * 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. + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project + +class FirebasePerfConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.findPlugin("com.google.firebase.firebase-perf").apply { + version = "1.4.1" + } + } + } + +} \ No newline at end of file diff --git a/core-ui/build.gradle.kts b/core-ui/build.gradle.kts index 5bc3bb10f..1d9624f25 100644 --- a/core-ui/build.gradle.kts +++ b/core-ui/build.gradle.kts @@ -44,4 +44,6 @@ dependencies { api(libs.androidx.compose.ui.util) api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime.livedata) + api(libs.androidx.metrics) + api(libs.androidx.tracing.ktx) } \ No newline at end of file diff --git a/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt new file mode 100644 index 000000000..6fdb948c7 --- /dev/null +++ b/core-ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/JankStatsExtensions.kt @@ -0,0 +1,90 @@ +/* + * 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 androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.DisposableEffectResult +import androidx.compose.runtime.DisposableEffectScope +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalView +import androidx.metrics.performance.PerformanceMetricsState +import androidx.metrics.performance.PerformanceMetricsState.MetricsStateHolder +import kotlinx.coroutines.CoroutineScope + +/** + * Retrieves [PerformanceMetricsState.MetricsStateHolder] from current [LocalView] and + * remembers it until the View changes. + * @see PerformanceMetricsState.getForHierarchy + */ +@Composable +fun rememberMetricsStateHolder(): MetricsStateHolder { + val localView = LocalView.current + + return remember(localView) { + PerformanceMetricsState.getForHierarchy(localView) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state. The side effect is + * re-launched if any of the [keys] value is not equal to the previous composition. + * @see JankMetricDisposableEffect if you need to work with DisposableEffect to cleanup added state. + */ +@Composable +fun JankMetricEffect( + vararg keys: Any?, + reportMetric: suspend CoroutineScope.(state: MetricsStateHolder) -> Unit +) { + val metrics = rememberMetricsStateHolder() + LaunchedEffect(metrics, *keys) { + reportMetric(metrics) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state that needs to be cleaned up. + * The side effect is re-launched if any of the [keys] value is not equal to the previous composition. + */ +@Composable +fun JankMetricDisposableEffect( + vararg keys: Any?, + reportMetric: DisposableEffectScope.(state: MetricsStateHolder) -> DisposableEffectResult +) { + val metrics = rememberMetricsStateHolder() + DisposableEffect(metrics, *keys) { + reportMetric(this, metrics) + } +} + +@Composable +fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) { + JankMetricEffect(scrollableState) { metricsHolder -> + snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress -> + metricsHolder.state?.apply { + if (isScrollInProgress) { + addState(stateName, "Scrolling=true") + } else { + removeState(stateName) + } + } + } + } +} diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt index d744db7cd..6099baaea 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/AuthorsCarousel.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple.rememberRipple @@ -58,6 +59,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor +import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank @Composable fun AuthorsCarousel( @@ -65,10 +67,15 @@ fun AuthorsCarousel( onAuthorClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier ) { + val lazyListState = rememberLazyListState() + val tag = "forYou:authors" + TrackScrollJank(scrollableState = lazyListState, stateName = tag) + LazyRow( - modifier = modifier.testTag("forYou:authors"), + modifier = modifier.testTag(tag), contentPadding = PaddingValues(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp) + horizontalArrangement = Arrangement.spacedBy(24.dp), + state = lazyListState ) { items(items = authors, key = { item -> item.author.id }) { followableAuthor -> AuthorItem( diff --git a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index a918f2a87..5b0222cdd 100644 --- a/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature-foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -46,7 +46,9 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api @@ -97,6 +99,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded +import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import kotlin.math.floor @Composable @@ -188,10 +191,16 @@ fun ForYouScreen( } } + val tag = "forYou:feed" + + val lazyListState = rememberLazyListState() + TrackScrollJank(scrollableState = lazyListState, stateName = tag) + LazyColumn( modifier = Modifier .fillMaxSize() - .testTag("forYou:feed"), + .testTag(tag), + state = lazyListState, ) { InterestsSelection( interestsSelectionState = interestsSelectionState, @@ -316,7 +325,11 @@ private fun TopicSelection( onTopicCheckedChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier ) = trace("TopicSelection") { + val lazyGridState = rememberLazyGridState() + TrackScrollJank(scrollableState = lazyGridState, stateName = "forYou:TopicSelection") + LazyHorizontalGrid( + state = lazyGridState, rows = GridCells.Fixed(3), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index 8986b9685..390fc8019 100644 --- a/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature-interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -43,6 +43,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewTopics +import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect @Composable fun InterestsRoute( @@ -64,6 +65,14 @@ fun InterestsRoute( switchTab = viewModel::switchTab, modifier = modifier ) + + JankMetricDisposableEffect(tabState) { metricsHolder -> + metricsHolder.state?.addState("Interests:TabState", "currentIndex:${tabState.currentIndex}") + + onDispose { + metricsHolder.state?.removeState("Interests:TabState") + } + } } @Composable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cd1e9bae..199be8faa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,12 +14,14 @@ androidxHiltNavigationCompose = "1.0.0" androidxLifecycle = "2.5.0-rc02" androidxMacroBenchmark = "1.1.0" androidxNavigation = "2.5.0" +androidxMetrics = "1.0.0-alpha01" androidxProfileinstaller = "1.2.0-rc01" androidxSavedState = "1.1.0" androidxStartup = "1.1.1" androidxWindowManager = "1.0.0" androidxTest = "1.4.0" androidxTestExt = "1.1.3" +androidxTracing = "1.1.0" androidxUiAutomator = "2.2.0" androidxWork = "2.7.1" coil = "2.1.0" @@ -70,6 +72,7 @@ androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", ve androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } +androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-savedstate-ktx = { group = "androidx.savedstate", name = "savedstate-ktx", version.ref= "androidxSavedState"} @@ -81,6 +84,7 @@ androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espres androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" } +androidx-tracing-ktx = {group = "androidx.tracing", name="tracing-ktx", version.ref = "androidxTracing" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil"} diff --git a/sync/build.gradle.kts b/sync/build.gradle.kts index 66b762118..02cae2c55 100644 --- a/sync/build.gradle.kts +++ b/sync/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.startup) implementation(libs.androidx.work.ktx) implementation(libs.hilt.ext.work) diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index 5b99e603b..ffb425d1e 100644 --- a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.sync.workers import android.content.Context import androidx.hilt.work.HiltWorker +import androidx.tracing.traceAsync import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder @@ -59,15 +60,17 @@ class SyncWorker @AssistedInject constructor( appContext.syncForegroundInfo() override suspend fun doWork(): Result = withContext(ioDispatcher) { - // First sync the repositories in parallel - val syncedSuccessfully = awaitAll( - async { topicRepository.sync() }, - async { authorsRepository.sync() }, - async { newsRepository.sync() }, - ).all { it } + traceAsync("Sync", 0) { + // First sync the repositories in parallel + val syncedSuccessfully = awaitAll( + async { topicRepository.sync() }, + async { authorsRepository.sync() }, + async { newsRepository.sync() }, + ).all { it } - if (syncedSuccessfully) Result.success() - else Result.retry() + if (syncedSuccessfully) Result.success() + else Result.retry() + } } override suspend fun getChangeListVersions(): ChangeListVersions =