diff --git a/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt index 805a1268c13..8061768de04 100644 --- a/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt @@ -1,5 +1,6 @@ package org.oppia.android.app.splash +import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri @@ -9,12 +10,15 @@ import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.home.HomeActivity import org.oppia.android.app.model.AppStartupState import org.oppia.android.app.model.AppStartupState.BuildFlavorNoticeMode import org.oppia.android.app.model.AppStartupState.StartupMode import org.oppia.android.app.model.BuildFlavor import org.oppia.android.app.model.DeprecationNoticeType import org.oppia.android.app.model.DeprecationResponse +import org.oppia.android.app.model.Profile +import org.oppia.android.app.model.ProfileOnboardingState import org.oppia.android.app.notice.AutomaticAppDeprecationNoticeDialogFragment import org.oppia.android.app.notice.BetaNoticeDialogFragment import org.oppia.android.app.notice.DeprecationNoticeActionResponse @@ -31,6 +35,7 @@ import org.oppia.android.domain.locale.LocaleController import org.oppia.android.domain.onboarding.AppStartupStateController import org.oppia.android.domain.onboarding.DeprecationController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.topic.PrimeTopicAssetsController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult @@ -51,6 +56,7 @@ private const val OS_UPDATE_NOTICE_DIALOG_FRAGMENT_TAG = "os_update_notice_dialo private const val SPLASH_INIT_STATE_DATA_PROVIDER_ID = "splash_init_state_data_provider" /** The presenter for [SplashActivity]. */ +@SuppressLint("CustomSplashScreen") @ActivityScope class SplashActivityPresenter @Inject constructor( private val activity: AppCompatActivity, @@ -65,6 +71,7 @@ class SplashActivityPresenter @Inject constructor( private val currentBuildFlavor: BuildFlavor, @EnableAppAndOsDeprecation private val enableAppAndOsDeprecation: PlatformParameterValue, + private val profileManagementController: ProfileManagementController ) { lateinit var startupMode: StartupMode @@ -290,6 +297,9 @@ class SplashActivityPresenter @Inject constructor( AutomaticAppDeprecationNoticeDialogFragment::newInstance ) } + StartupMode.ONBOARDING_FLOW_V2 -> { + getProfileOnboardingState() + } else -> { // In all other cases (including errors when the startup state fails to load or is // defaulted), assume the user needs to be onboarded. @@ -299,6 +309,64 @@ class SplashActivityPresenter @Inject constructor( } } + private fun getProfileOnboardingState() { + appStartupStateController.getProfileOnboardingState().toLiveData().observe( + activity, + { result -> + when (result) { + is AsyncResult.Success -> { + computeLoginRoute(result.value) + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "SplashActivity", + "Encountered unexpected non-successful result when fetching onboarding state", + result.error + ) + } + is AsyncResult.Pending -> {} + } + } + ) + } + + private fun computeLoginRoute(onboardingState: ProfileOnboardingState) { + when (onboardingState) { + ProfileOnboardingState.NEW_INSTALL -> { + activity.startActivity(OnboardingActivity.createOnboardingActivity(activity)) + activity.finish() + } + ProfileOnboardingState.SOLE_LEARNER_PROFILE -> { + profileManagementController.getProfiles().toLiveData().observe( + activity, + { result -> + when (result) { + is AsyncResult.Success -> { + val internalProfileId = getSoleLearnerProfile(result.value)?.id?.internalId + activity.startActivity(HomeActivity.createHomeActivity(activity, internalProfileId)) + activity.finish() + } + is AsyncResult.Pending -> {} // no-op + is AsyncResult.Failure -> { + oppiaLogger.e( + "SplashActivity", "Failed to retrieve the list of profiles", result.error + ) + } + } + } + ) + } + else -> { + activity.startActivity(ProfileChooserActivity.createProfileChooserActivity(activity)) + activity.finish() + } + } + } + + private fun getSoleLearnerProfile(profiles: List): Profile? { + return profiles.find { it.isAdmin } + } + private fun computeInitStateDataProvider(): DataProvider { val startupStateDataProvider = appStartupStateController.getAppStartupState() val systemAppLanguageLocaleDataProvider = translationController.getSystemLanguageLocale() diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt index ee30f69b061..7539c9f3513 100644 --- a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt +++ b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt @@ -4,21 +4,21 @@ import org.oppia.android.app.model.AppStartupState import org.oppia.android.app.model.AppStartupState.BuildFlavorNoticeMode import org.oppia.android.app.model.AppStartupState.StartupMode import org.oppia.android.app.model.BuildFlavor -import org.oppia.android.app.model.DeprecationResponseDatabase import org.oppia.android.app.model.OnboardingState +import org.oppia.android.app.model.ProfileOnboardingState import org.oppia.android.data.persistence.PersistentCacheStore import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders.Companion.combineWith +import org.oppia.android.util.data.DataProviders.Companion.transform import org.oppia.android.util.extensions.getStringFromBundle import org.oppia.android.util.locale.OppiaLocale -import org.oppia.android.util.platformparameter.EnableAppAndOsDeprecation -import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject -import javax.inject.Provider import javax.inject.Singleton private const val APP_STARTUP_STATE_PROVIDER_ID = "app_startup_state_data_provider_id" +private const val PROFILE_ONBOARDING_STATE_PROVIDER_ID = "profile_onboarding_state_data_provider_id" /** Controller for persisting and retrieving the user's initial app state upon opening the app. */ @Singleton @@ -29,8 +29,7 @@ class AppStartupStateController @Inject constructor( private val machineLocale: OppiaLocale.MachineLocale, private val currentBuildFlavor: BuildFlavor, private val deprecationController: DeprecationController, - @EnableAppAndOsDeprecation - private val enableAppAndOsDeprecation: Provider>, + private val profileManagementController: ProfileManagementController ) { private val onboardingFlowStore by lazy { cacheStoreFactory.create("on_boarding_flow", OnboardingState.getDefaultInstance()) @@ -104,7 +103,10 @@ class AppStartupStateController @Inject constructor( APP_STARTUP_STATE_PROVIDER_ID ) { onboardingState, deprecationResponseDatabase -> AppStartupState.newBuilder().apply { - startupMode = computeAppStartupMode(onboardingState, deprecationResponseDatabase) + startupMode = deprecationController.processStartUpMode( + onboardingState, + deprecationResponseDatabase + ) buildFlavorNoticeMode = computeBuildNoticeMode(onboardingState, startupMode) }.build() } @@ -127,23 +129,6 @@ class AppStartupStateController @Inject constructor( } } - private fun computeAppStartupMode( - onboardingState: OnboardingState, - deprecationResponseDatabase: DeprecationResponseDatabase - ): StartupMode { - // Process and return either a StartupMode.APP_IS_DEPRECATED, StartupMode.USER_IS_ONBOARDED or - // StartupMode.USER_NOT_YET_ONBOARDED if the app and OS deprecation feature flag is not enabled. - if (!enableAppAndOsDeprecation.get().value) { - return when { - hasAppExpired() -> StartupMode.APP_IS_DEPRECATED - onboardingState.alreadyOnboardedApp -> StartupMode.USER_IS_ONBOARDED - else -> StartupMode.USER_NOT_YET_ONBOARDED - } - } - - return deprecationController.processStartUpMode(onboardingState, deprecationResponseDatabase) - } - private fun computeBuildNoticeMode( onboardingState: OnboardingState, startupMode: StartupMode @@ -190,4 +175,26 @@ class AppStartupStateController @Inject constructor( expirationDate?.isBeforeToday() ?: true } else false } + + /** Returns the state of the app based on the number and type of existing profiles. */ + fun getProfileOnboardingState(): DataProvider { + return profileManagementController.getProfiles() + .transform(PROFILE_ONBOARDING_STATE_PROVIDER_ID) { profileList -> + when { + profileList.size > 1 -> { + ProfileOnboardingState.MULTIPLE_PROFILES + } + profileList.size == 1 -> { + if (profileList.first().isAdmin && profileList.first().pin.isNotBlank()) { + ProfileOnboardingState.ADMIN_PROFILE_ONLY + } else { + ProfileOnboardingState.SOLE_LEARNER_PROFILE + } + } + else -> { + ProfileOnboardingState.NEW_INSTALL + } + } + } + } } diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt index 37afcfd0b51..86133913ff1 100644 --- a/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt +++ b/domain/src/main/java/org/oppia/android/domain/onboarding/DeprecationController.kt @@ -15,6 +15,7 @@ import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders import org.oppia.android.util.data.DataProviders.Companion.transform import org.oppia.android.util.extensions.getVersionCode +import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2 import org.oppia.android.util.platformparameter.ForcedAppUpdateVersionCode import org.oppia.android.util.platformparameter.LowestSupportedApiLevel import org.oppia.android.util.platformparameter.OptionalAppUpdateVersionCode @@ -41,7 +42,9 @@ class DeprecationController @Inject constructor( @ForcedAppUpdateVersionCode private val forcedAppUpdateVersionCode: Provider>, @LowestSupportedApiLevel - private val lowestSupportedApiLevel: Provider> + private val lowestSupportedApiLevel: Provider>, + @EnableOnboardingFlowV2 + private val enableOnboardingFlowV2: PlatformParameterValue ) { /** Create an instance of [PersistentCacheStore] that contains a [DeprecationResponseDatabase]. */ private val deprecationStore by lazy { @@ -173,6 +176,10 @@ class DeprecationController @Inject constructor( return StartupMode.OPTIONAL_UPDATE_AVAILABLE } + if (enableOnboardingFlowV2.value) { + return StartupMode.ONBOARDING_FLOW_V2 + } + return StartupMode.USER_IS_ONBOARDED } else return StartupMode.USER_NOT_YET_ONBOARDED } diff --git a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt index 53cc1399317..d9f1abe739e 100644 --- a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt +++ b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.model.Profile import org.oppia.android.app.model.ProfileAvatar import org.oppia.android.app.model.ProfileDatabase import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ProfileType import org.oppia.android.app.model.ReadingTextSize import org.oppia.android.data.persistence.PersistentCacheStore import org.oppia.android.data.persistence.PersistentCacheStore.PublishMode @@ -30,6 +31,7 @@ import org.oppia.android.util.data.DataProviders.Companion.transformAsync import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics import org.oppia.android.util.platformparameter.EnableLoggingLearnerStudyIds +import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2 import org.oppia.android.util.platformparameter.PlatformParameterValue import org.oppia.android.util.profile.DirectoryManagementUtil import org.oppia.android.util.profile.ProfileNameValidator @@ -89,7 +91,9 @@ class ProfileManagementController @Inject constructor( private val enableLearnerStudyAnalytics: PlatformParameterValue, @EnableLoggingLearnerStudyIds private val enableLoggingLearnerStudyIds: PlatformParameterValue, - private val profileNameValidator: ProfileNameValidator + private val profileNameValidator: ProfileNameValidator, + @EnableOnboardingFlowV2 + private val enableOnboardingFlowV2: PlatformParameterValue ) { private var currentProfileId: Int = DEFAULT_LOGGED_OUT_INTERNAL_PROFILE_ID private val profileDataStore = @@ -290,6 +294,10 @@ class ProfileManagementController @Inject constructor( avatarImageUri = imageUri } else avatarColorRgb = colorRgb }.build() + + if (enableOnboardingFlowV2.value) { + this.profileType = computeProfileType(it) + } }.build() val wasProfileEverAdded = it.profilesCount > 0 @@ -306,6 +314,27 @@ class ProfileManagementController @Inject constructor( } } + private fun computeProfileType(profileDatabase: ProfileDatabase): ProfileType { + return if (isAdminWithPin(profileDatabase)) { + ProfileType.SUPERVISOR + } else { + if (profileDatabase.profilesCount == 1) { + ProfileType.SOLE_LEARNER + } else { + ProfileType.ADDITIONAL_LEARNER + } + } + } + + private fun isAdminWithPin(profileDatabase: ProfileDatabase): Boolean { + profileDatabase.profilesMap.values.forEach { + if (it.isAdmin && it.hasPin) { + return true + } + } + return false + } + /** * Updates the profile avatar of an existing profile. * @@ -684,7 +713,7 @@ class ProfileManagementController @Inject constructor( return@createInMemoryDataProviderAsync AsyncResult.Success(0) } AsyncResult.Failure( - ProfileNotFoundException( + ProfileManagementController.ProfileNotFoundException( "ProfileId ${profileId.internalId} is" + " not associated with an existing profile" ) diff --git a/model/src/main/proto/onboarding.proto b/model/src/main/proto/onboarding.proto index 4cefc9213d7..71373953fae 100644 --- a/model/src/main/proto/onboarding.proto +++ b/model/src/main/proto/onboarding.proto @@ -33,6 +33,10 @@ message AppStartupState { // they are using an OS version that is no longer supported. The user should be shown a prompt // to update their OS. OS_IS_DEPRECATED = 5; + + // Indicates that the onboarding flow shown to the user should be the new flow. + // TODO(#): Remove after onboarding project stabilization. + ONBOARDING_FLOW_V2 = 6; } // Describes different notices that may be shown to the user on startup depending on whether @@ -69,6 +73,7 @@ message AppStartupState { // Stores the completion state of the user's progress through the app onboarding flow. message OnboardingState { + // TODO(#): Remove after onboarding project stabilization // Indicates whether user has fully completed the onboarding flow. bool already_onboarded_app = 1; @@ -83,3 +88,21 @@ message OnboardingState { // the general availability version of the app after having previously used a pre-release version. bool permanently_dismissed_ga_upgrade_notice = 4; } + +// Indicates the state of the app with regards to the number and type of existing profiles. +enum ProfileOnboardingState { + // Indicates that the number or type of profiles is unknown. + PROFILE_ONBOARDING_STATE_UNSPECIFIED = 0; + + // Indicates that this is a new app install given that there are no existing profiles. + NEW_INSTALL = 1; + + // Indicates that there is only one profile and it is a sole learner profile. + SOLE_LEARNER_PROFILE = 2; + + // Indicates that there is only one profile and it is an admin profile. + ADMIN_PROFILE_ONLY = 3; + + // Indicates that there are multiple profiles on the device. + MULTIPLE_PROFILES = 4; +} \ No newline at end of file diff --git a/model/src/main/proto/oppia_logger.proto b/model/src/main/proto/oppia_logger.proto index e2d6cc455ea..d07f01641b6 100644 --- a/model/src/main/proto/oppia_logger.proto +++ b/model/src/main/proto/oppia_logger.proto @@ -37,6 +37,11 @@ message EventLog { // The audio language selection context at the time of this event's creation. AudioTranslationLanguageSelection audio_translation_language_selection = 7; + // The profileId and profileType to which this event corresponds, or empty if this event is not tied to a particular + // profile. This is only used for diagnostic purposes as events are only ever logged anonymously + // at source. + ProfileContext profile_context = 9; + // Structure of an activity context. message Context { // Deprecated exploration context. This is now handled via the open_exploration_activity context @@ -178,6 +183,18 @@ message EventLog { } } + // Structure of a ProfileContext which contains the profileId and profileType to which this event + // corresponds. + message ProfileContext { + // The profile to which this event corresponds, or empty if this event is not tied to a particular + // profile. This is only used for diagnostic purposes as events are only ever logged anonymously + // at source. + ProfileId profile_id = 1; + + // Represents the type of user profile. + ProfileType profile_type = 2; + } + // Structure of a question context. message QuestionContext { // The active question ID when the event is logged. diff --git a/model/src/main/proto/profile.proto b/model/src/main/proto/profile.proto index aadd1f34881..b83d8702c60 100644 --- a/model/src/main/proto/profile.proto +++ b/model/src/main/proto/profile.proto @@ -87,6 +87,12 @@ message Profile { // Represents the epoch timestamp in milliseconds when the nps survey was previously shown in // this profile. int64 survey_last_shown_timestamp_ms = 18; + + // Represents the type of user which informs the configuration options available to them. + ProfileType profile_type = 19; + + // Indicates whether this user has completed the onboarding flow. + bool already_onboarded_profile = 20; } // Represents a profile avatar image. @@ -140,3 +146,18 @@ enum AudioLanguage { ARABIC_LANGUAGE = 7; NIGERIAN_PIDGIN_LANGUAGE = 8; } + +// Represents the type of learner profile. +enum ProfileType { + // The undefined ProfileType. + PROFILE_TYPE_UNSPECIFIED = 0; + + // Represents a single learner profile without an admin pin set. + SOLE_LEARNER = 1; + + // Represents an admin profile when there are more than one profiles. + SUPERVISOR = 2; + + // Represents a non-admin profile in a multiple profile setup. + ADDITIONAL_LEARNER = 3; +}