diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt index f0e35b7d079..f90e36ac141 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/LoggingIdentifierController.kt @@ -17,6 +17,7 @@ import javax.inject.Inject import javax.inject.Singleton private const val SESSION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.session_id" +private const val APP_SESSION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.app_session_id" private const val INSTALLATION_ID_DATA_PROVIDER_ID = "LoggingIdentifierController.installation_id" /** Controller that handles logging identifiers related operations. */ @@ -34,14 +35,22 @@ class LoggingIdentifierController @Inject constructor( private val installationRandomSeed = baseRandom.nextLong() private val sessionRandomSeed = baseRandom.nextLong() private val learnerRandomSeed = baseRandom.nextLong() + private val appSessionRandomSeed = baseRandom.nextLong() private val installationIdRandom by lazy { Random(installationRandomSeed) } private val sessionIdRandom by lazy { Random(sessionRandomSeed) } private val learnerIdRandom by lazy { Random(learnerRandomSeed) } + private val appSessionIdRandom by lazy { Random(appSessionRandomSeed) } private val sessionId by lazy { MutableStateFlow(computeSessionId()) } + private val appSessionId by lazy { MutableStateFlow(computeAppSessionId()) } private val sessionIdDataProvider by lazy { dataProviders.run { sessionId.convertToAutomaticDataProvider(SESSION_ID_DATA_PROVIDER_ID) } } + private val appSessionIdDataProvider by lazy { + dataProviders.run { + appSessionId.convertToAutomaticDataProvider(APP_SESSION_ID_DATA_PROVIDER_ID) + } + } private val installationIdStore by lazy { persistentCacheStoreFactory.create( cacheName = "device_context_database", DeviceContextDatabase.getDefaultInstance() @@ -101,6 +110,14 @@ class LoggingIdentifierController @Inject constructor( */ fun getSessionId(): DataProvider = sessionIdDataProvider + /** + * Returns an in-memory data provider pointing to a class variable of [appSessionId]. + * + * This ID is unique to each app session. A session starts when the app is opened and ends when + * the app is destroyed by the Android system. + */ + fun getAppSessionId(): DataProvider = appSessionIdDataProvider + /** * Returns the [StateFlow] backing the current session ID indicated by [getSessionId]. * @@ -110,6 +127,15 @@ class LoggingIdentifierController @Inject constructor( */ fun getSessionIdFlow(): StateFlow = sessionId + /** + * Returns the [StateFlow] backing the current app session ID indicated by [getAppSessionId]. + * + * Where the [DataProvider] returned by [getAppSessionId] can be composed by domain controllers or + * observed by the UI layer, the [StateFlow] returned by this method can be observed in background + * contexts. + */ + fun getAppSessionIdFlow(): StateFlow = appSessionId + /** * Regenerates [sessionId] and notifies the data provider. * @@ -123,6 +149,8 @@ class LoggingIdentifierController @Inject constructor( private fun computeSessionId(): String = sessionIdRandom.randomUuid().toString() + private fun computeAppSessionId(): String = appSessionIdRandom.randomUuid().toString() + private fun computeInstallationId(): String { return machineLocale.run { MessageDigest.getInstance("SHA-1") diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt index 7ad7f2bcaa1..24eaf81f248 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserver.kt @@ -38,6 +38,7 @@ class ApplicationLifecycleObserver @Inject constructor( private val profileManagementController: ProfileManagementController, private val oppiaLogger: OppiaLogger, private val performanceMetricsLogger: PerformanceMetricsLogger, + private val featureFlagsLogger: FeatureFlagsLogger, private val performanceMetricsController: PerformanceMetricsController, private val cpuPerformanceSnapshotter: CpuPerformanceSnapshotter, @LearnerAnalyticsInactivityLimitMillis private val inactivityLimitMillis: Long, @@ -84,6 +85,7 @@ class ApplicationLifecycleObserver @Inject constructor( ProcessLifecycleOwner.get().lifecycle.addObserver(this) application.registerActivityLifecycleCallbacks(this) logApplicationStartupMetrics() + logAllFeatureFlags() cpuPerformanceSnapshotter.initialiseSnapshotter() } @@ -172,6 +174,22 @@ class ApplicationLifecycleObserver @Inject constructor( } } + private fun logAllFeatureFlags() { + CoroutineScope(backgroundDispatcher).launch { + // TODO(#5341): Replace appSessionId generation to the modified Twitter snowflake algorithm. + val appSessionId = loggingIdentifierController.getAppSessionIdFlow().value + featureFlagsLogger.logAllFeatureFlags(appSessionId) + }.invokeOnCompletion { failure -> + if (failure != null) { + oppiaLogger.e( + "ActivityLifecycleObserver", + "Encountered error while logging feature flags.", + failure + ) + } + } + } + private fun logAppInForegroundTime() { CoroutineScope(backgroundDispatcher).launch { val sessionId = loggingIdentifierController.getSessionIdFlow().value diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel index 3c54e3d6c53..3a91ed280e8 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel @@ -121,6 +121,7 @@ kt_android_library( deps = [ ":application_lifecycle_listener", ":cpu_performance_snapshotter", + ":feature_flags_logger", ":learner_analytics_inactivity_limit_millis", ":performance_metrics_controller", "//domain/src/main/java/org/oppia/android/domain/oppialogger:logging_identifier_controller", @@ -191,6 +192,20 @@ kt_android_library( visibility = ["//:oppia_api_visibility"], ) +kt_android_library( + name = "feature_flags_logger", + srcs = [ + "FeatureFlagsLogger.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//:dagger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:controller", + "//third_party:javax_inject_javax_inject", + ], +) + kt_android_library( name = "data_controller", srcs = [ diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLogger.kt new file mode 100644 index 00000000000..1175075751e --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLogger.kt @@ -0,0 +1,142 @@ +package org.oppia.android.domain.oppialogger.analytics + +import org.oppia.android.app.model.EventLog +import org.oppia.android.app.model.EventLog.FeatureFlagItemContext +import org.oppia.android.app.model.EventLog.FeatureFlagListContext +import org.oppia.android.util.platformparameter.APP_AND_OS_DEPRECATION +import org.oppia.android.util.platformparameter.DOWNLOADS_SUPPORT +import org.oppia.android.util.platformparameter.EDIT_ACCOUNTS_OPTIONS_UI +import org.oppia.android.util.platformparameter.ENABLE_NPS_SURVEY +import org.oppia.android.util.platformparameter.ENABLE_ONBOARDING_FLOW_V2 +import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION +import org.oppia.android.util.platformparameter.EXTRA_TOPIC_TABS_UI +import org.oppia.android.util.platformparameter.EnableAppAndOsDeprecation +import org.oppia.android.util.platformparameter.EnableDownloadsSupport +import org.oppia.android.util.platformparameter.EnableEditAccountsOptionsUi +import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi +import org.oppia.android.util.platformparameter.EnableFastLanguageSwitchingInLesson +import org.oppia.android.util.platformparameter.EnableInteractionConfigChangeStateRetention +import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics +import org.oppia.android.util.platformparameter.EnableLoggingLearnerStudyIds +import org.oppia.android.util.platformparameter.EnableNpsSurvey +import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2 +import org.oppia.android.util.platformparameter.EnablePerformanceMetricsCollection +import org.oppia.android.util.platformparameter.EnableSpotlightUi +import org.oppia.android.util.platformparameter.FAST_LANGUAGE_SWITCHING_IN_LESSON +import org.oppia.android.util.platformparameter.INTERACTION_CONFIG_CHANGE_STATE_RETENTION +import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS +import org.oppia.android.util.platformparameter.LOGGING_LEARNER_STUDY_IDS +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPOTLIGHT_UI +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Convenience logger for feature flags. + * + * This logger is meant to be used for feature flag-related logging on every app launch. It is + * primarily used within the ApplicationLifecycleObserver to log the status of feature flags in a + * given app session. + */ +@Singleton +class FeatureFlagsLogger @Inject constructor( + private val analyticsController: AnalyticsController, + @EnableDownloadsSupport + private val enableDownloadsSupport: PlatformParameterValue, + @EnableExtraTopicTabsUi + private val enableExtraTopicTabsUi: PlatformParameterValue, + @EnableLearnerStudyAnalytics + private val enableLearnerStudyAnalytics: PlatformParameterValue, + @EnableFastLanguageSwitchingInLesson + private val enableFastLanguageSwitchingInLesson: PlatformParameterValue, + @EnableLoggingLearnerStudyIds + private val enableLoggingLearnerStudyIds: PlatformParameterValue, + @EnableEditAccountsOptionsUi + private val enableEditAccountsOptionsUi: PlatformParameterValue, + @EnablePerformanceMetricsCollection + private val enablePerformanceMetricsCollection: PlatformParameterValue, + @EnableSpotlightUi + private val enableSpotlightUi: PlatformParameterValue, + @EnableInteractionConfigChangeStateRetention + private val enableInteractionConfigChangeStateRetention: PlatformParameterValue, + @EnableAppAndOsDeprecation + private val enableAppAndOsDeprecation: PlatformParameterValue, + @EnableNpsSurvey + private val enableNpsSurvey: PlatformParameterValue, + @EnableOnboardingFlowV2 + private val enableOnboardingFlowV2: PlatformParameterValue +) { + /** + * A variable containing a list of all the feature flags in the app. + * + * @return a list of key-value pairs of [String] and [PlatformParameterValue] + */ + private var featureFlagItemMap: Map> = mapOf( + DOWNLOADS_SUPPORT to enableDownloadsSupport, + EXTRA_TOPIC_TABS_UI to enableExtraTopicTabsUi, + LEARNER_STUDY_ANALYTICS to enableLearnerStudyAnalytics, + FAST_LANGUAGE_SWITCHING_IN_LESSON to enableFastLanguageSwitchingInLesson, + LOGGING_LEARNER_STUDY_IDS to enableLoggingLearnerStudyIds, + EDIT_ACCOUNTS_OPTIONS_UI to enableEditAccountsOptionsUi, + ENABLE_PERFORMANCE_METRICS_COLLECTION to enablePerformanceMetricsCollection, + SPOTLIGHT_UI to enableSpotlightUi, + INTERACTION_CONFIG_CHANGE_STATE_RETENTION to enableInteractionConfigChangeStateRetention, + APP_AND_OS_DEPRECATION to enableAppAndOsDeprecation, + ENABLE_NPS_SURVEY to enableNpsSurvey, + ENABLE_ONBOARDING_FLOW_V2 to enableOnboardingFlowV2 + ) + + /** + * This method can be used to override the featureFlagItemMap and sets its value to the given map. + * + * @param featureFlagItemMap denotes the map of feature flag names to their corresponding + * [PlatformParameterValue]s + */ + fun setFeatureFlagItemMap(featureFlagItemMap: Map>) { + this.featureFlagItemMap = featureFlagItemMap + } + + /** + * This method logs the name, enabled status and sync status of all feature flags to Firebase. + * + * @param appSessionId denotes the id of the current appInForeground session + */ + fun logAllFeatureFlags(appSessionId: String) { + val featureFlagItemList = mutableListOf() + for (flag in featureFlagItemMap) { + featureFlagItemList.add( + createFeatureFlagItemContext(flag) + ) + } + + // TODO(#5341): Set the UUID value for this context + val featureFlagContext = FeatureFlagListContext.newBuilder() + .setAppSessionId(appSessionId) + .addAllFeatureFlags(featureFlagItemList) + .build() + + analyticsController.logLowPriorityEvent( + EventLog.Context.newBuilder() + .setFeatureFlagListContext(featureFlagContext) + .build(), + profileId = null + ) + } + + /** + * Creates an [EventLog] context for the feature flags to be logged. + * + * @param flagDetails denotes the key-value pair of the feature flag name and its corresponding + * [PlatformParameterValue] + * @return an [EventLog.Context] for the feature flags to be logged + */ + private fun createFeatureFlagItemContext( + flagDetails: Map.Entry>, + ): FeatureFlagItemContext { + return FeatureFlagItemContext.newBuilder() + .setFlagName(flagDetails.key) + .setFlagEnabledState(flagDetails.value.value) + .setFlagSyncStatus(flagDetails.value.syncStatus) + .build() + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt index af4b5b5d538..b44cba6a347 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/LoggingIdentifierControllerTest.kt @@ -309,6 +309,58 @@ class LoggingIdentifierControllerTest { assertThat(sessionIdFlow.value).isEqualTo("59aea8d4-af4b-3249-b889-dfeba06d0495") } + @Test + fun testGetAppSessionId_initialState_returnsRandomId() { + val appSessionIdProvider = loggingIdentifierController.getAppSessionId() + + val appSessionId = monitorFactory.waitForNextSuccessfulResult(appSessionIdProvider) + assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14") + } + + @Test + fun testGetAppSessionId_secondCall_returnsSameRandomId() { + monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getAppSessionId()) + + val sessionIdProvider = loggingIdentifierController.getAppSessionId() + + // The second call should return the same ID (since the ID doesn't automatically change). + val appSessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14") + } + + @Test + fun testGetAppSessionIdFlow_initialState_returnsFlowWithRandomId() { + val appSessionIdFlow = loggingIdentifierController.getAppSessionIdFlow() + + val appSessionId = appSessionIdFlow.waitForLatestValue() + assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14") + } + + @Test + fun testGetAppSessionIdFlow_secondCall_returnsFlowWithSameRandomId() { + loggingIdentifierController.getSessionIdFlow().waitForLatestValue() + + val appSessionIdFlow = loggingIdentifierController.getAppSessionIdFlow() + + // The second call should return the same ID (since the ID doesn't automatically change). + val appSessionId = appSessionIdFlow.waitForLatestValue() + assertThat(appSessionId).isEqualTo("2a11efe0-70f8-3a40-8d94-4fc3a2bd4f14") + } + + @Test + fun testGetAppSessionId_onSecondAppOpen_returnsDifferentRandomId() { + monitorFactory.ensureDataProviderExecutes(loggingIdentifierController.getAppSessionId()) + + // Simulate a second app open. + TestLoggingIdentifierModule.applicationIdSeed = SECOND_APP_OPEN_APPLICATION_ID + setUpNewTestApplicationComponent() + + // The app session ID should be different on the second app open. + val appSessionIdProvider = loggingIdentifierController.getAppSessionId() + val appSessionId = monitorFactory.waitForNextSuccessfulResult(appSessionIdProvider) + assertThat(appSessionId).isEqualTo("c9d50545-33dc-3231-a1db-6a2672498c74") + } + private fun writeFileCache(cacheName: String, value: T) { getCacheFile(cacheName).writeBytes(value.toByteArray()) } diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt index 1ad7b21770c..8d45736b0d9 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/ApplicationLifecycleObserverTest.kt @@ -23,6 +23,7 @@ import org.oppia.android.app.model.EventLog import org.oppia.android.app.model.EventLog.Context.ActivityContextCase import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.APP_IN_FOREGROUND_TIME import org.oppia.android.app.model.OppiaMetricLog +import org.oppia.android.app.model.PlatformParameter import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ScreenName import org.oppia.android.data.backends.gae.NetworkLoggingInterceptor @@ -37,6 +38,9 @@ import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.TextInputActionTestActivity import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat +import org.oppia.android.testing.platformparameter.EnableTestFeatureFlag +import org.oppia.android.testing.platformparameter.EnableTestFeatureFlagWithEnabledDefault +import org.oppia.android.testing.platformparameter.TEST_FEATURE_FLAG import org.oppia.android.testing.platformparameter.TestPlatformParameterModule import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers @@ -55,6 +59,7 @@ import org.oppia.android.util.logging.GlobalLogLevel import org.oppia.android.util.logging.LogLevel import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.PlatformParameterValue import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import retrofit2.Retrofit @@ -116,12 +121,21 @@ class ApplicationLifecycleObserverTest { @Inject lateinit var networkLoggingInterceptor: NetworkLoggingInterceptor + @Inject + lateinit var featureFlagsLogger: FeatureFlagsLogger + @field:[JvmField Inject ForegroundCpuLoggingTimePeriodMillis] var foregroundCpuLoggingTimePeriodMillis: Long = Long.MIN_VALUE @field:[JvmField Inject BackgroundCpuLoggingTimePeriodMillis] var backgroundCpuLoggingTimePeriodMillis: Long = Long.MIN_VALUE + @field:[Inject EnableTestFeatureFlag] + lateinit var testFeatureFlag: PlatformParameterValue + + @field:[Inject EnableTestFeatureFlagWithEnabledDefault] + lateinit var testFeatureFlagWithEnabledDefault: PlatformParameterValue + @get:Rule var activityRule = ActivityScenarioRule( @@ -404,6 +418,34 @@ class ApplicationLifecycleObserverTest { assertThat(event.currentScreen).isEqualTo(ScreenName.BACKGROUND_SCREEN) } + @Test + fun testObserver_onAppInForeground_logsAllFeatureFlags() { + setUpTestApplicationComponent() + + featureFlagsLogger.setFeatureFlagItemMap( + mapOf(TEST_FEATURE_FLAG to testFeatureFlag) + ) + + // TODO(#5240): Replace appSessionId generation to the modified Twitter snowflake algorithm. + val sessionIdProvider = loggingIdentifierController.getAppSessionId() + val sessionId = monitorFactory.waitForNextSuccessfulResult(sessionIdProvider) + + applicationLifecycleObserver.onCreate() + testCoroutineDispatchers.runCurrent() + testCoroutineDispatchers.advanceTimeBy(foregroundCpuLoggingTimePeriodMillis) + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + + assertThat(eventLog).hasFeatureFlagContextThat { + hasSessionIdThat().isEqualTo(sessionId) + hasFeatureFlagItemContextThatAtIndex(0) { + hasFeatureFlagNameThat().isEqualTo(TEST_FEATURE_FLAG) + hasFeatureFlagEnabledStateThat().isEqualTo(false) + hasFeatureFlagSyncStateThat().isEqualTo(PlatformParameter.SyncStatus.NOT_SYNCED_FROM_SERVER) + } + } + } + @Test fun testObserver_onAppInForeground_thenInBackground_logsAppInForegroundTime() { setUpTestApplicationComponent() diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel index 8db519ab7ed..ecdc2be1dbf 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel @@ -291,3 +291,35 @@ oppia_android_test( "//utility/src/main/java/org/oppia/android/util/networking:debug_module", ], ) + +oppia_android_test( + name = "FeatureFlagsLoggerTest", + srcs = ["FeatureFlagsLoggerTest.kt"], + custom_package = "org.oppia.android.domain.oppialogger.analytics", + test_class = "org.oppia.android.domain.oppialogger.analytics.FeatureFlagsLoggerTest", + test_manifest = "//domain:test_manifest", + deps = [ + "//:dagger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:feature_flags_logger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/junit:oppia_parameterized_test_runner", + "//testing/src/main/java/org/oppia/android/testing/junit:parameterized_robolectric_test_runner", + "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject", + "//testing/src/main/java/org/oppia/android/testing/logging:sync_status_test_module", + "//testing/src/main/java/org/oppia/android/testing/platformparameter:test_module", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLoggerTest.kt new file mode 100644 index 00000000000..d62953c56f0 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLoggerTest.kt @@ -0,0 +1,280 @@ +package org.oppia.android.domain.oppialogger.analytics + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.PlatformParameter.SyncStatus +import org.oppia.android.domain.oppialogger.EventLogStorageCacheSize +import org.oppia.android.domain.oppialogger.ExceptionLogStorageCacheSize +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.FakeAnalyticsEventLogger +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Iteration +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.Parameter +import org.oppia.android.testing.junit.OppiaParameterizedTestRunner.SelectRunnerPlatform +import org.oppia.android.testing.junit.ParameterizedRobolectricTestRunner +import org.oppia.android.testing.logging.EventLogSubject.Companion.assertThat +import org.oppia.android.testing.logging.SyncStatusTestModule +import org.oppia.android.testing.platformparameter.EnableTestFeatureFlag +import org.oppia.android.testing.platformparameter.EnableTestFeatureFlagWithEnabledDefault +import org.oppia.android.testing.platformparameter.TEST_FEATURE_FLAG +import org.oppia.android.testing.platformparameter.TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULTS +import org.oppia.android.testing.platformparameter.TestPlatformParameterModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestCoroutineDispatchers +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.EnableConsoleLog +import org.oppia.android.util.logging.EnableFileLog +import org.oppia.android.util.logging.GlobalLogLevel +import org.oppia.android.util.logging.LogLevel +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.platformparameter.APP_AND_OS_DEPRECATION +import org.oppia.android.util.platformparameter.DOWNLOADS_SUPPORT +import org.oppia.android.util.platformparameter.EDIT_ACCOUNTS_OPTIONS_UI +import org.oppia.android.util.platformparameter.ENABLE_NPS_SURVEY +import org.oppia.android.util.platformparameter.ENABLE_ONBOARDING_FLOW_V2 +import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION +import org.oppia.android.util.platformparameter.EXTRA_TOPIC_TABS_UI +import org.oppia.android.util.platformparameter.FAST_LANGUAGE_SWITCHING_IN_LESSON +import org.oppia.android.util.platformparameter.INTERACTION_CONFIG_CHANGE_STATE_RETENTION +import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS +import org.oppia.android.util.platformparameter.LOGGING_LEARNER_STUDY_IDS +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.platformparameter.SPOTLIGHT_UI +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [FeatureFlagsLogger]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(OppiaParameterizedTestRunner::class) +@SelectRunnerPlatform(ParameterizedRobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + application = FeatureFlagsLoggerTest.TestApplication::class, + sdk = [Build.VERSION_CODES.O] +) +class FeatureFlagsLoggerTest { + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject lateinit var featureFlagsLogger: FeatureFlagsLogger + @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger + + @field:[Inject EnableTestFeatureFlag] + lateinit var testFeatureFlag: PlatformParameterValue + @field:[Inject EnableTestFeatureFlagWithEnabledDefault] + lateinit var testFeatureFlagWithEnabledDefault: PlatformParameterValue + + @Parameter var index: Int = Int.MIN_VALUE + @Parameter lateinit var flagName: String + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testLogFeatureFlags_logFeatureFlags_hasEmptyUserUuid() { + // TODO(#5341): The user UUID is not set in this test context and is expected to be empty. + featureFlagsLogger.logAllFeatureFlags(TEST_SESSION_ID) + testCoroutineDispatchers.runCurrent() + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFeatureFlagContextThat { + hasUniqueUserUuidThat().isEmpty() + } + } + + @Test + fun testLogFeatureFlags_logFeatureFlags_hasCorrectSessionId() { + featureFlagsLogger.logAllFeatureFlags(TEST_SESSION_ID) + testCoroutineDispatchers.runCurrent() + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFeatureFlagContextThat { + hasSessionIdThat().isEqualTo(TEST_SESSION_ID) + } + } + + @Test + fun testLogFeatureFlags_logsTestFeatureFlag_hasCorrectDefaultValues() { + featureFlagsLogger.setFeatureFlagItemMap( + mapOf(TEST_FEATURE_FLAG to testFeatureFlag) + ) + featureFlagsLogger.logAllFeatureFlags(TEST_SESSION_ID) + + testCoroutineDispatchers.runCurrent() + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFeatureFlagContextThat { + hasFeatureFlagItemContextThatAtIndex(0) { + hasFeatureFlagNameThat().isEqualTo(TEST_FEATURE_FLAG) + hasFeatureFlagEnabledStateThat().isEqualTo(false) + hasFeatureFlagSyncStateThat().isEqualTo(SyncStatus.NOT_SYNCED_FROM_SERVER) + } + } + } + + @Test + fun testLogFeatureFlags_logsTestFeatureFlagWithEnabledDefaults_hasCorrectDefaultValues() { + featureFlagsLogger.setFeatureFlagItemMap( + mapOf(TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULTS to testFeatureFlagWithEnabledDefault) + ) + featureFlagsLogger.logAllFeatureFlags(TEST_SESSION_ID) + + testCoroutineDispatchers.runCurrent() + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFeatureFlagContextThat { + hasFeatureFlagItemContextThatAtIndex(0) { + hasFeatureFlagNameThat().isEqualTo(TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULTS) + hasFeatureFlagEnabledStateThat().isEqualTo(true) + hasFeatureFlagSyncStateThat().isEqualTo(SyncStatus.SYNCED_FROM_SERVER) + } + } + } + + @Test + fun testLogFeatureFlags_correctNumberOfFeatureFlagsIsLogged() { + val expectedFeatureFlagCount = 12 + + featureFlagsLogger.logAllFeatureFlags(TEST_SESSION_ID) + testCoroutineDispatchers.runCurrent() + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFeatureFlagContextThat { + hasFeatureFlagItemCountThat().isEqualTo(expectedFeatureFlagCount) + } + } + + @Test + @Iteration("downloads_support", "index=0", "flagName=$DOWNLOADS_SUPPORT") + @Iteration("extra_topic_tabs_ui", "index=1", "flagName=$EXTRA_TOPIC_TABS_UI") + @Iteration("learner_study_analytics", "index=2", "flagName=$LEARNER_STUDY_ANALYTICS") + @Iteration( + "fast_language_switching_in_lesson", "index=3", + "flagName=$FAST_LANGUAGE_SWITCHING_IN_LESSON" + ) + @Iteration("logging_learner_study_ids", "index=4", "flagName=$LOGGING_LEARNER_STUDY_IDS") + @Iteration("edit_accounts_options_ui", "index=5", "flagName=$EDIT_ACCOUNTS_OPTIONS_UI") + @Iteration( + "enable_performance_metrics_collection", "index=6", + "flagName=$ENABLE_PERFORMANCE_METRICS_COLLECTION" + ) + @Iteration("spotlight_ui", "index=7", "flagName=$SPOTLIGHT_UI") + @Iteration( + "interaction_config_change_state_retention", "index=8", + "flagName=$INTERACTION_CONFIG_CHANGE_STATE_RETENTION" + ) + @Iteration("app_and_os_deprecation", "index=9", "flagName=$APP_AND_OS_DEPRECATION") + @Iteration("enable_nps_survey", "index=10", "flagName=$ENABLE_NPS_SURVEY") + @Iteration("enable_onboarding_flow_v2", "index=11", "flagName=$ENABLE_ONBOARDING_FLOW_V2") + fun testLogFeatureFlags_allFeatureFlagNamesAreLogged() { + featureFlagsLogger.logAllFeatureFlags(TEST_SESSION_ID) + + testCoroutineDispatchers.runCurrent() + + val eventLog = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(eventLog).hasFeatureFlagContextThat { + hasFeatureFlagItemContextThatAtIndex(index) { + hasFeatureFlagNameThat().isEqualTo(flagName) + } + } + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private companion object { + private const val TEST_SESSION_ID = "test_session_id" + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + @Module + class TestLogStorageModule { + @Provides + @EventLogStorageCacheSize + fun provideEventLogStorageCacheSize(): Int = 2 + + @Provides + @ExceptionLogStorageCacheSize + fun provideExceptionLogStorageCacheSize(): Int = 2 + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, TestLogReportingModule::class, RobolectricModule::class, + TestDispatcherModule::class, TestLogStorageModule::class, + NetworkConnectionUtilDebugModule::class, LocaleProdModule::class, FakeOppiaClockModule::class, + TestPlatformParameterModule::class, PlatformParameterSingletonModule::class, + LoggingIdentifierModule::class, SyncStatusTestModule::class, AssetModule::class + ] + ) + + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(featureFlagLoggerTest: FeatureFlagsLoggerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerFeatureFlagsLoggerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(featureFlagLoggerTest: FeatureFlagsLoggerTest) { + component.inject(featureFlagLoggerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/model/src/main/proto/BUILD.bazel b/model/src/main/proto/BUILD.bazel index 83fc35a5664..8a917b4108d 100644 --- a/model/src/main/proto/BUILD.bazel +++ b/model/src/main/proto/BUILD.bazel @@ -47,6 +47,7 @@ oppia_proto_library( deps = [ ":exploration_proto", ":languages_proto", + ":platform_parameter_proto", ":profile_proto", ":survey_proto", ], diff --git a/model/src/main/proto/oppia_logger.proto b/model/src/main/proto/oppia_logger.proto index 74b429f9d8b..07411ad365b 100644 --- a/model/src/main/proto/oppia_logger.proto +++ b/model/src/main/proto/oppia_logger.proto @@ -5,6 +5,7 @@ package model; import "languages.proto"; import "profile.proto"; import "survey.proto"; +import "platform_parameter.proto"; option java_package = "org.oppia.android.app.model"; option java_multiple_files = true; @@ -191,7 +192,10 @@ message EventLog { // The event being logged is related to the amount spent by the user with the app in // foreground. AppInForegroundTimeContext app_in_foreground_time = 48; - + + // The event being logged contains feature flags and their current enabled and sync state. + FeatureFlagListContext feature_flag_list_context = 43; + // The event being logged is related to lesson progress(checkpoint) saving success. ExplorationContext progress_saving_success_context = 49; @@ -465,6 +469,30 @@ message EventLog { float foreground_time = 3; } + // Structure of a feature flag list context. + message FeatureFlagListContext { + // The client-local UUID of the user. + string unique_user_uuid = 1; + + // The id of the current session. + string app_session_id = 2; + + // List of Feature Flag items. + repeated FeatureFlagItemContext feature_flags = 3; + } + + // Structure for an individual feature flag log context. + message FeatureFlagItemContext { + // The name of the feature flag or sync status flag. + string flag_name = 1; + + // The enabled state of the feature flag. + bool flag_enabled_state = 2; + + // The sync status of the feature flag or sync status flag. + PlatformParameter.SyncStatus flag_sync_status = 3; + } + // Supported priority of events for event logging enum Priority { // The undefined priority of an event @@ -473,7 +501,7 @@ message EventLog { // but can be removed if they're the only ones there and size limit exceeds. ESSENTIAL = 1; // The priority of events whose logs can be removed from the storage if the size exceeds a - // certain limit + // certain limit. OPTIONAL = 2; } } diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 3c4cebcfe47..f8d2be95573 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -316,6 +316,7 @@ file_content_checks { exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/numericexpressioninput/NumericExpressionInputMatchesExactlyWithRuleClassifierProviderTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/classify/rules/numericexpressioninput/NumericExpressionInputMatchesUpToTrivialManipulationsRuleClassifierProviderTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/onboarding/AppStartupStateControllerTest.kt" + exempted_file_name: "domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/FeatureFlagsLoggerTest.kt" exempted_file_name: "domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt" exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" exempted_file_name: "testing/src/test/java/org/oppia/android/testing/logging/TestSyncStatusManagerTest.kt" diff --git a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt index ad3e89f662e..3009fe88a79 100644 --- a/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt +++ b/testing/src/main/java/org/oppia/android/testing/logging/EventLogSubject.kt @@ -56,8 +56,10 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_CA import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.START_OVER_EXPLORATION_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SUBMIT_ANSWER_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.SWITCH_IN_LESSON_LANGUAGE +import org.oppia.android.app.model.EventLog.FeatureFlagItemContext import org.oppia.android.app.model.MarketFitAnswer import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.PlatformParameter.SyncStatus import org.oppia.android.app.model.SurveyQuestionName import org.oppia.android.app.model.UserTypeAnswer import org.oppia.android.app.model.WrittenTranslationLanguageSelection @@ -1059,6 +1061,25 @@ class EventLogSubject private constructor( hasBeginSurveyContextThat().block() } + /** + * Verifies the [EventLog]'s context and returns a [FeatureFlagListContextSubject] to test the + * corresponding context. + */ + fun hasFeatureFlagContextThat(): FeatureFlagListContextSubject { + return FeatureFlagListContextSubject.assertThat( + actual.context.featureFlagListContext + ) + } + + /** + * Verifies the [EventLog]'s context and executes [block]. + */ + fun hasFeatureFlagContextThat( + block: FeatureFlagListContextSubject.() -> Unit + ) { + hasFeatureFlagContextThat().block() + } + /** * Verifies that the [EventLog] under test has a context corresponding to * [OPTIONAL_RESPONSE] (per [EventLog.Context.getActivityContextCase]). @@ -2169,6 +2190,127 @@ class EventLogSubject private constructor( } } + /** + * Truth subject for verifying properties of [EventLog.FeatureFlagListContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.FeatureFlagContext] proto can be verified through inherited methods. + * + * Call [FeatureFlagListContextSubject.assertThat] to create the subject. + */ + class FeatureFlagListContextSubject private constructor( + metadata: FailureMetadata, + private val actual: EventLog.FeatureFlagListContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test [EventLog.FeatureFlagListContext.getUniqueUserUuid]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasUniqueUserUuidThat(): StringSubject = assertThat(actual.uniqueUserUuid) + + /** + * Returns a [StringSubject] to test [EventLog.FeatureFlagListContext.getAppSessionId]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasSessionIdThat(): StringSubject = assertThat(actual.appSessionId) + + /** + * Returns a [IntegerSubject] to test [EventLog.FeatureFlagListContext.getFeatureFlagsCount]. + * + * This method never fails since the underlying property defaults to 0 if it's not defined in the + * context. + */ + fun hasFeatureFlagItemCountThat(): IntegerSubject = assertThat(actual.featureFlagsCount) + + /** + * Returns a [FeatureFlagItemContextSubject] to test + * [EventLog.FeatureFlagListContext.getFeatureFlagsList]. + * + * This method never fails since the underlying property defaults to empty object if it's not + * defined in the context. + */ + fun hasFeatureFlagItemContextThatAtIndex(index: Int): FeatureFlagItemContextSubject { + return FeatureFlagItemContextSubject.assertThat(actual.featureFlagsList[index]) + } + + /** + * Verifies the [EventLog]'s context and executes [block] in the same way as + * [hasFeatureFlagItemContextThatAtIndex] except for the conditions of, and subject returned by, + * [hasFeatureFlagItemContextThatAtIndex]. + */ + fun hasFeatureFlagItemContextThatAtIndex( + index: Int, + block: FeatureFlagItemContextSubject.() -> Unit + ) { + hasFeatureFlagItemContextThatAtIndex(index).block() + } + + companion object { + /** + * Returns a new [FeatureFlagListContextSubject] to verify aspects of the specified + * [EventLog.FeatureFlagListContext] value. + */ + fun assertThat(actual: EventLog.FeatureFlagListContext): FeatureFlagListContextSubject = + assertAbout(::FeatureFlagListContextSubject).that(actual) + } + } + + /** + * Truth subject for verifying properties of [EventLog.FeatureFlagItemContext]s. + * + * Note that this class is also a [LiteProtoSubject] so other aspects of the underlying + * [EventLog.FeatureFlagItemContext] proto can be verified through inherited + * methods. + * + * Call [FeatureFlagItemContextSubject.assertThat] to create the subject. + */ + class FeatureFlagItemContextSubject private constructor( + metadata: FailureMetadata, + private val actual: FeatureFlagItemContext + ) : LiteProtoSubject(metadata, actual) { + /** + * Returns a [StringSubject] to test + * [EventLog.FeatureFlagItemContext.getFlagName]. + * + * This method never fails since the underlying property defaults to empty string if it's not + * defined in the context. + */ + fun hasFeatureFlagNameThat(): StringSubject = assertThat(actual.flagName) + + /** + * Returns a [BooleanSubject] to test + * [EventLog.FeatureFlagItemContext.getFlagEnabledState]. + * + * This method never fails since the underlying property defaults to false if it's not + * defined in the context. + */ + fun hasFeatureFlagEnabledStateThat(): BooleanSubject = assertThat(actual.flagEnabledState) + + /** + * Returns a [ComparableSubject] to test + * [EventLog.FeatureFlagItemContext.getFlagSyncStatus]. + * + * This method never fails since the underlying property defaults to the unspecified enum value + * if it's not defined in the context. + */ + fun hasFeatureFlagSyncStateThat(): ComparableSubject = + assertThat(actual.flagSyncStatus) + + companion object { + /** + * Returns a new [FeatureFlagItemContextSubject] to verify aspects of the specified + * [EventLog.FeatureFlagItemContext] value. + */ + fun assertThat(actual: FeatureFlagItemContext?): + FeatureFlagItemContextSubject = + assertAbout(::FeatureFlagItemContextSubject).that(actual) + } + } + companion object { /** Returns a new [EventLogSubject] to verify aspects of the specified [EventLog] value. */ fun assertThat(actual: EventLog): EventLogSubject = assertAbout(::EventLogSubject).that(actual) diff --git a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterConstants.kt b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterConstants.kt index a904314f795..4e3156be33f 100644 --- a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterConstants.kt +++ b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterConstants.kt @@ -66,3 +66,23 @@ const val TEST_INTEGER_PARAM_DEFAULT_VALUE = 0 * Server value for the test integer platform parameter. Only used in tests related to platform parameter. */ const val TEST_INTEGER_PARAM_SERVER_VALUE = 1 + +/** + * Qualifier for a test feature flag + */ +@Qualifier annotation class EnableTestFeatureFlag + +/** Name of the test feature flag. */ +const val TEST_FEATURE_FLAG = "android_test_feature_flag" + +/** Default value of the test feature flag. */ +const val TEST_FEATURE_FLAG_DEFAULT_VALUE = false + +/** Qualifier for a test feature flag with an enabled default value. */ +@Qualifier annotation class EnableTestFeatureFlagWithEnabledDefault + +/** Name of the test feature flag with an enabled default value. */ +const val TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULTS = "android_test_feature_flag_with_enabled_default" + +/** Default value of the test feature flag with an enabled default value. */ +const val TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULT_VALUE = true diff --git a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt index baaae39a962..fc848d39233 100644 --- a/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt +++ b/testing/src/main/java/org/oppia/android/testing/platformparameter/TestPlatformParameterModule.kt @@ -4,11 +4,13 @@ import android.content.Context import androidx.annotation.VisibleForTesting import dagger.Module import dagger.Provides +import org.oppia.android.app.model.PlatformParameter import org.oppia.android.util.extensions.getVersionCode import org.oppia.android.util.platformparameter.APP_AND_OS_DEPRECATION import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING_DEFAULT_VALUE import org.oppia.android.util.platformparameter.CacheLatexRendering +import org.oppia.android.util.platformparameter.DOWNLOADS_SUPPORT import org.oppia.android.util.platformparameter.ENABLE_APP_AND_OS_DEPRECATION_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_DOWNLOADS_SUPPORT_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_EDIT_ACCOUNTS_OPTIONS_UI_DEFAULT_VALUE @@ -67,10 +69,37 @@ import javax.inject.Singleton /* Fake Platform Parameter Module that provides individual Platform Parameters for testing. */ @Module class TestPlatformParameterModule { + @Provides + @EnableTestFeatureFlag + fun provideEnableTestFeatureFlag( + platformParameterSingleton: PlatformParameterSingleton + ): PlatformParameterValue { + return platformParameterSingleton.getBooleanPlatformParameter(TEST_FEATURE_FLAG) + ?: PlatformParameterValue.createDefaultParameter(TEST_FEATURE_FLAG_DEFAULT_VALUE) + } + + @Provides + @EnableTestFeatureFlagWithEnabledDefault + fun provideEnableTestFeatureFlagWithEnabledDefault( + platformParameterSingleton: PlatformParameterSingleton + ): PlatformParameterValue { + return platformParameterSingleton.getBooleanPlatformParameter( + TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULTS + ) + ?: PlatformParameterValue.createDefaultParameter( + defaultValue = TEST_FEATURE_FLAG_WITH_ENABLED_DEFAULT_VALUE, + defaultSyncStatus = PlatformParameter.SyncStatus.SYNCED_FROM_SERVER + ) + } + @Provides @EnableDownloadsSupport - fun provideEnableDownloadsSupport(): PlatformParameterValue = - PlatformParameterValue.createDefaultParameter(enableDownloadsSupport) + fun provideEnableDownloadsSupport( + platformParameterSingleton: PlatformParameterSingleton + ): PlatformParameterValue { + return platformParameterSingleton.getBooleanPlatformParameter(DOWNLOADS_SUPPORT) + ?: PlatformParameterValue.createDefaultParameter(enableDownloadsSupport) + } @TestStringParam @Provides diff --git a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt index 2b5ad794f74..cb95c888227 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt @@ -21,6 +21,7 @@ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.CONSOLE_ import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.DELETE_PROFILE_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.END_CARD_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.EXIT_EXPLORATION_CONTEXT +import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FEATURE_FLAG_LIST_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.FINISH_EXPLORATION_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.HINT_UNLOCKED_CONTEXT import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG @@ -76,6 +77,7 @@ import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.Co import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ConsoleLoggerContext import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.EmptyContext import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ExplorationContext +import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.FeatureFlagContext import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.ForegroundAppTimeContext import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.HintContext import org.oppia.android.util.logging.EventBundleCreator.EventActivityContext.LearnerDetailsContext @@ -110,6 +112,7 @@ import org.oppia.android.app.model.EventLog.CompleteAppOnboardingContext as Comp import org.oppia.android.app.model.EventLog.ConceptCardContext as ConceptCardEventContext import org.oppia.android.app.model.EventLog.ConsoleLoggerContext as ConsoleLoggerEventContext import org.oppia.android.app.model.EventLog.ExplorationContext as ExplorationEventContext +import org.oppia.android.app.model.EventLog.FeatureFlagListContext as FeatureFlagListEventContext import org.oppia.android.app.model.EventLog.HintContext as HintEventContext import org.oppia.android.app.model.EventLog.LearnerDetailsContext as LearnerDetailsEventContext import org.oppia.android.app.model.EventLog.MandatorySurveyResponseContext as MandatorySurveyResponseEventContext @@ -266,6 +269,7 @@ class EventBundleCreator @Inject constructor( RETROFIT_CALL_FAILED_CONTEXT -> RetrofitCallFailedContext(activityName, retrofitCallFailedContext) APP_IN_FOREGROUND_TIME -> ForegroundAppTimeContext(activityName, appInForegroundTime) + FEATURE_FLAG_LIST_CONTEXT -> FeatureFlagContext(activityName, featureFlagListContext) INSTALL_ID_FOR_FAILED_ANALYTICS_LOG -> SensitiveStringContext(activityName, installIdForFailedAnalyticsLog, "install_id") ACTIVITYCONTEXT_NOT_SET, null -> EmptyContext(activityName) // No context to create here. @@ -661,6 +665,24 @@ class EventBundleCreator @Inject constructor( store.putNonSensitiveValue("foreground_time", foregroundTime) } } + + /** The [EventActivityContext] corresponding to [FeatureFlagListEventContext]s. */ + class FeatureFlagContext( + activityName: String, + value: FeatureFlagListEventContext + ) : EventActivityContext(activityName, value) { + override fun EventLog.FeatureFlagListContext.storeValue(store: PropertyStore) { + val featureFlagNames = featureFlagsList.map { it.flagName } + val featureFlagSyncStatuses = featureFlagsList.map { it.flagSyncStatus } + val featureFlagEnabledStates = featureFlagsList.map { it.flagEnabledState } + + store.putNonSensitiveValue("uuid", uniqueUserUuid) + store.putNonSensitiveValue("app_session_id", appSessionId) + store.putNonSensitiveValue("feature_flag_names", featureFlagNames) + store.putNonSensitiveValue("feature_flag_enabled_states", featureFlagEnabledStates) + store.putNonSensitiveValue("feature_flag_sync_statuses", featureFlagSyncStatuses) + } + } } /** Represents an [OppiaMetricLog] loggable metric (denoted by [LoggableMetricTypeCase]). */ diff --git a/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt b/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt index bfb1e9dc71c..342333d65eb 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/KenyaAlphaEventTypeToHumanReadableNameConverterImpl.kt @@ -57,6 +57,7 @@ class KenyaAlphaEventTypeToHumanReadableNameConverterImpl @Inject constructor() ActivityContextCase.ABANDON_SURVEY -> "abandon_survey" ActivityContextCase.MANDATORY_RESPONSE -> "mandatory_response" ActivityContextCase.OPTIONAL_RESPONSE -> "optional_response" + ActivityContextCase.FEATURE_FLAG_LIST_CONTEXT -> "feature_flag_list" ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG -> "failed_analytics_log" ActivityContextCase.ACTIVITYCONTEXT_NOT_SET -> "unknown_activity_context" ActivityContextCase.COMPLETE_APP_ONBOARDING -> "complete_app_onboarding" diff --git a/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt b/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt index 4673e2c6382..55097655e74 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/StandardEventTypeToHumanReadableNameConverterImpl.kt @@ -67,6 +67,7 @@ class StandardEventTypeToHumanReadableNameConverterImpl @Inject constructor() : ActivityContextCase.ABANDON_SURVEY -> "abandon_survey" ActivityContextCase.MANDATORY_RESPONSE -> "mandatory_response" ActivityContextCase.OPTIONAL_RESPONSE -> "optional_response" + ActivityContextCase.FEATURE_FLAG_LIST_CONTEXT -> "feature_flag_list" ActivityContextCase.INSTALL_ID_FOR_FAILED_ANALYTICS_LOG, ActivityContextCase.ACTIVITYCONTEXT_NOT_SET -> "ERROR_internal_logging_failure" ActivityContextCase.COMPLETE_APP_ONBOARDING -> "complete_app_onboarding" diff --git a/utility/src/main/java/org/oppia/android/util/platformparameter/FeatureFlagConstants.kt b/utility/src/main/java/org/oppia/android/util/platformparameter/FeatureFlagConstants.kt index 4b65f954c7a..e125d2a1fc6 100644 --- a/utility/src/main/java/org/oppia/android/util/platformparameter/FeatureFlagConstants.kt +++ b/utility/src/main/java/org/oppia/android/util/platformparameter/FeatureFlagConstants.kt @@ -162,7 +162,7 @@ const val ENABLE_APP_AND_OS_DEPRECATION_DEFAULT_VALUE = false annotation class EnableNpsSurvey /** Name of the feature flag that toggles the NPS Survey. */ -const val ENABLE_NPS_SURVEY = "enable_nps_survey" +const val ENABLE_NPS_SURVEY = "android_enable_nps_survey" /** Default value of the feature flag corresponding to [EnableNpsSurvey]. */ const val ENABLE_NPS_SURVEY_DEFAULT_VALUE = true @@ -172,7 +172,7 @@ const val ENABLE_NPS_SURVEY_DEFAULT_VALUE = true annotation class EnableOnboardingFlowV2 /** Name of the feature flag that toggles the new onboarding flow. */ -const val ENABLE_ONBOARDING_FLOW_V2 = "enable_onboarding_flow_v2" +const val ENABLE_ONBOARDING_FLOW_V2 = "android_enable_onboarding_flow_v2" /** Default value of the feature flag corresponding to [EnableOnboardingFlowV2]. */ const val ENABLE_ONBOARDING_FLOW_V2_DEFAULT_VALUE = false