Skip to content

Commit

Permalink
Fix Part of #4938: Profile Configuration and Migration (#5387)
Browse files Browse the repository at this point in the history
<!-- READ ME FIRST: Please fill in the explanation section below and
check off every point from the Essential Checklist! -->
## Explanation
Fix Part of #4938: Configure profile new creation and migration of
existing profiles.

## When Onboarding V2 is Enabled:
### Sole Learner Onboarding
Once the learner has created a profile, and have landed on the
introduction screen, the profile is marked as
`started_profile_oboarding`, and a corresponding event log is added.
This configuration facilitates the user resuming onboarding, if they
didn't complete it the first time round.

After selecting the audio language, the profile is logged in, and routed
to the home screen.
On the home screen, the profile is updated to indicate
`completed_profile_oboarding` and 3 events are expected for the first
time login flow, in order:
1.  Open home event
2.  Profile Onboarding Completed event
3.  App Onboarding Completed event

The App Onboarding Completed event is only logged for the first profile
on the device, while the Profile Onboarding Completed event is logged
for every profile on first login.

### Supervisor Profile Onboarding
From the Profile Type screen, the supervisor flow launches the profile
chooser screen, showing the Admin profile, which on click launches the
homescreen.

The same 3 events as above are expected for the first time login flow.

### Login Routing
Returning users should be routed to an appropriate landing page as
follows:
A sole learner profile will be routed directly to their home screen 

Returning admin profiles and non-solo learner profiles will always be
routed to the profile selection screen

A sole learner who started, but did not finish onboarding would be
routed to the Introduction Screen.

Returning sole and none-sole learners created when the feature flag was
disabled, would be directed to the onboarding screen. Profile migration
happens in place when profiles are fetched by the controller.

### Profile Creation and Migration
For migration purposes, the `getProfile()` function has been updated to
compute the `profileType` field when the feature flag is enabled.
Existing Admin profiles will be migrated to have the SUPERVISOR type,
existing Admin profiles with no pins set will be migrated to have the
SOLE_LEARNER type while the remaining accounts will be of the
ADDITIONAL_LEARNER type. New profiles created will also contain the
respective enum type based on the intended categorization.
Flag off then on:

[device-2024-06-21-073957.webm](https://github.com/oppia/oppia-android/assets/59600948/7584ce32-305b-4743-878e-64c94dc52e66)

Flag on then off:

[device-2024-06-21-074114.webm](https://github.com/oppia/oppia-android/assets/59600948/87860084-c75c-4b6c-a37f-c2459a3e7794)

### Misc
- There are general Kdoc formatting fixes all over the place.
- `ProfileTestHelper` Has been modified to create a sole, pinless admin
profile.
- There are other minor changes to fix tests that have been impacted by
changes in this PR.
- The changes in `DeprecationController` are purely visual to make the
code more readable.
- `ClassroomListFragmentTest` required refactor to support toggling
multiple feature flags.

### Profile Onboarding Events
|Onborading Flow v1| Onboarding Flow v2|
|---|---|
|<img
src="https://github.com/oppia/oppia-android/assets/59600948/990c1c0a-ce71-4685-b986-bb4396ce28e6"
width="350">|<img
src="https://github.com/oppia/oppia-android/assets/59600948/c5eca79b-36d9-4a9c-a685-e0d7df3c399a"
width="350">|


## Essential Checklist
<!-- Please tick the relevant boxes by putting an "x" in them. -->
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

---------

Co-authored-by: Ben Henning <[email protected]>
  • Loading branch information
adhiamboperes and BenHenning authored Nov 29, 2024
1 parent 2f68639 commit 2eeec89
Show file tree
Hide file tree
Showing 46 changed files with 2,212 additions and 210 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,24 @@ import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
import org.oppia.android.app.activity.route.ActivityRouter
import org.oppia.android.app.drawer.ExitProfileDialogFragment
import org.oppia.android.app.drawer.TAG_SWITCH_PROFILE_DIALOG
import org.oppia.android.app.home.ExitProfileListener
import org.oppia.android.app.home.RouteToRecentlyPlayedListener
import org.oppia.android.app.home.RouteToTopicListener
import org.oppia.android.app.home.RouteToTopicPlayStoryListener
import org.oppia.android.app.model.DestinationScreen
import org.oppia.android.app.model.ExitProfileDialogArguments
import org.oppia.android.app.model.HighlightItem
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.ProfileType
import org.oppia.android.app.model.RecentlyPlayedActivityParams
import org.oppia.android.app.model.RecentlyPlayedActivityTitle
import org.oppia.android.app.model.ScreenName.CLASSROOM_LIST_ACTIVITY
import org.oppia.android.app.topic.TopicActivity.Companion.createTopicActivityIntent
import org.oppia.android.app.topic.TopicActivity.Companion.createTopicPlayStoryActivityIntent
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2
import org.oppia.android.util.platformparameter.PlatformParameterValue
import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId
import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId
import javax.inject.Inject
Expand All @@ -32,7 +36,8 @@ class ClassroomListActivity :
InjectableAutoLocalizedAppCompatActivity(),
RouteToTopicListener,
RouteToTopicPlayStoryListener,
RouteToRecentlyPlayedListener {
RouteToRecentlyPlayedListener,
ExitProfileListener {
@Inject
lateinit var classroomListActivityPresenter: ClassroomListActivityPresenter

Expand All @@ -44,6 +49,10 @@ class ClassroomListActivity :

private var internalProfileId: Int = -1

@Inject
@field:EnableOnboardingFlowV2
lateinit var enableOnboardingFlowV2: PlatformParameterValue<Boolean>

companion object {
/** Returns a new [Intent] to route to [ClassroomListActivity] for a specified [profileId]. */
fun createClassroomListActivity(context: Context, profileId: ProfileId?): Intent {
Expand All @@ -68,22 +77,6 @@ class ClassroomListActivity :
classroomListActivityPresenter.handleOnRestart()
}

override fun onBackPressed() {
val previousFragment =
supportFragmentManager.findFragmentByTag(TAG_SWITCH_PROFILE_DIALOG)
if (previousFragment != null) {
supportFragmentManager.beginTransaction().remove(previousFragment).commitNow()
}
val exitProfileDialogArguments =
ExitProfileDialogArguments
.newBuilder()
.setHighlightItem(HighlightItem.NONE)
.build()
val dialogFragment = ExitProfileDialogFragment
.newInstance(exitProfileDialogArguments = exitProfileDialogArguments)
dialogFragment.showNow(supportFragmentManager, TAG_SWITCH_PROFILE_DIALOG)
}

override fun routeToRecentlyPlayed(recentlyPlayedActivityTitle: RecentlyPlayedActivityTitle) {
val recentlyPlayedActivityParams =
RecentlyPlayedActivityParams
Expand Down Expand Up @@ -121,4 +114,24 @@ class ClassroomListActivity :
)
)
}

override fun exitProfile(profileType: ProfileType) {
val previousFragment =
supportFragmentManager.findFragmentByTag(TAG_SWITCH_PROFILE_DIALOG)
if (previousFragment != null) {
supportFragmentManager.beginTransaction().remove(previousFragment).commitNow()
}
val exitProfileDialogArguments =
ExitProfileDialogArguments
.newBuilder().apply {
if (enableOnboardingFlowV2.value) {
this.profileType = profileType
}
this.highlightItem = HighlightItem.NONE
}
.build()
val dialogFragment = ExitProfileDialogFragment
.newInstance(exitProfileDialogArguments = exitProfileDialogArguments)
dialogFragment.showNow(supportFragmentManager, TAG_SWITCH_PROFILE_DIALOG)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.oppia.android.app.classroom
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
Expand Down Expand Up @@ -36,6 +37,7 @@ import org.oppia.android.app.classroom.promotedlist.PromotedStoryList
import org.oppia.android.app.classroom.topiclist.AllTopicsHeaderText
import org.oppia.android.app.classroom.topiclist.TopicCard
import org.oppia.android.app.classroom.welcome.WelcomeText
import org.oppia.android.app.home.ExitProfileListener
import org.oppia.android.app.home.HomeItemViewModel
import org.oppia.android.app.home.RouteToTopicPlayStoryListener
import org.oppia.android.app.home.WelcomeViewModel
Expand All @@ -48,6 +50,8 @@ import org.oppia.android.app.home.topiclist.TopicSummaryViewModel
import org.oppia.android.app.model.ClassroomSummary
import org.oppia.android.app.model.LessonThumbnail
import org.oppia.android.app.model.LessonThumbnailGraphic
import org.oppia.android.app.model.Profile
import org.oppia.android.app.model.ProfileType
import org.oppia.android.app.model.TopicSummary
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.app.utility.datetime.DateTimeUtil
Expand All @@ -59,9 +63,13 @@ import org.oppia.android.domain.oppialogger.analytics.AnalyticsController
import org.oppia.android.domain.profile.ProfileManagementController
import org.oppia.android.domain.topic.TopicListController
import org.oppia.android.domain.translation.TranslationController
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProviders.Companion.toLiveData
import org.oppia.android.util.locale.OppiaLocale
import org.oppia.android.util.parser.html.StoryHtmlParserEntityType
import org.oppia.android.util.parser.html.TopicHtmlParserEntityType
import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2
import org.oppia.android.util.platformparameter.PlatformParameterValue
import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId
import javax.inject.Inject

Expand All @@ -82,14 +90,18 @@ class ClassroomListFragmentPresenter @Inject constructor(
private val dateTimeUtil: DateTimeUtil,
private val translationController: TranslationController,
private val machineLocale: OppiaLocale.MachineLocale,
private val appStartupStateController: AppStartupStateController,
private val analyticsController: AnalyticsController,
@EnableOnboardingFlowV2
private val enableOnboardingFlowV2: PlatformParameterValue<Boolean>,
private val appStartupStateController: AppStartupStateController
) {
private val routeToTopicPlayStoryListener = activity as RouteToTopicPlayStoryListener
private val exitProfileListener = activity as ExitProfileListener
private lateinit var binding: ClassroomListFragmentBinding
private lateinit var classroomListViewModel: ClassroomListViewModel
private var internalProfileId: Int = -1
private val profileId = activity.intent.extractCurrentUserProfileId()
private var onBackPressedCallback: OnBackPressedCallback? = null

/** Creates and returns the view for the [ClassroomListFragment]. */
fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? {
Expand Down Expand Up @@ -155,6 +167,10 @@ class ClassroomListFragmentPresenter @Inject constructor(
}
)

profileManagementController.getProfile(profileId).toLiveData().observe(fragment) {
processProfileResult(it)
}

return binding.root
}

Expand Down Expand Up @@ -190,26 +206,25 @@ class ClassroomListFragmentPresenter @Inject constructor(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClassroomListScreen() {
val groupedItems = classroomListViewModel.homeItemViewModelListLiveData.value
?.plus(classroomListViewModel.topicList)
?.groupBy { it::class }
val groupedItems = (
classroomListViewModel.homeItemViewModelListLiveData.value.orEmpty() +
classroomListViewModel.topicList
)
.groupBy { it::class }
val topicListSpanCount = integerResource(id = R.integer.home_span_count)
val listState = rememberLazyListState()
val classroomListIndex = groupedItems
?.flatMap { (type, items) -> items.map { type to it } }
?.indexOfFirst { it.first == AllClassroomsViewModel::class }
?: -1
.flatMap { (type, items) -> items.map { type to it } }
.indexOfFirst { it.first == AllClassroomsViewModel::class }

LazyColumn(
modifier = Modifier.testTag(CLASSROOM_LIST_SCREEN_TEST_TAG),
state = listState
) {
groupedItems?.forEach { (type, items) ->
groupedItems.forEach { (type, items) ->
when (type) {
WelcomeViewModel::class -> items.forEach { item ->
item {
WelcomeText(welcomeViewModel = item as WelcomeViewModel)
}
item { WelcomeText(welcomeViewModel = item as WelcomeViewModel) }
}
PromotedStoryListViewModel::class -> items.forEach { item ->
item {
Expand All @@ -223,26 +238,22 @@ class ClassroomListFragmentPresenter @Inject constructor(
item {
ComingSoonTopicList(
comingSoonTopicListViewModel = item as ComingSoonTopicListViewModel,
machineLocale = machineLocale,
machineLocale = machineLocale
)
}
}
AllClassroomsViewModel::class -> items.forEach { _ ->
item {
AllClassroomsHeaderText()
}
item { AllClassroomsHeaderText() }
}
ClassroomSummaryViewModel::class -> stickyHeader() {
ClassroomSummaryViewModel::class -> stickyHeader {
ClassroomList(
classroomSummaryList = items.map { it as ClassroomSummaryViewModel },
selectedClassroomId = classroomListViewModel.selectedClassroomId.get() ?: "",
selectedClassroomId = classroomListViewModel.selectedClassroomId.get().orEmpty(),
isSticky = listState.firstVisibleItemIndex >= classroomListIndex
)
}
AllTopicsViewModel::class -> items.forEach { _ ->
item {
AllTopicsHeaderText()
}
item { AllTopicsHeaderText() }
}
TopicSummaryViewModel::class -> {
gridItems(
Expand All @@ -259,12 +270,61 @@ class ClassroomListFragmentPresenter @Inject constructor(
}
}

private fun processProfileResult(result: AsyncResult<Profile>) {
when (result) {
is AsyncResult.Success -> {
val profile = result.value
val profileType = profile.profileType

if (enableOnboardingFlowV2.value && !profile.completedProfileOnboarding) {
// These asynchronous API calls do not block or wait for their results. They execute in
// the background and have minimal chances of interfering with the synchronous
// `handleBackPress` call below.
profileManagementController.markProfileOnboardingEnded(profileId)
if (profileType == ProfileType.SOLE_LEARNER || profileType == ProfileType.SUPERVISOR) {
appStartupStateController.markOnboardingFlowCompleted(profileId)
}
}

// This synchronous function call executes independently of the async calls above.
handleBackPress(profileType)
}
is AsyncResult.Failure -> {
oppiaLogger.e(
"ClassroomListFragment", "Failed to fetch profile with id:$profileId", result.error
)
Profile.getDefaultInstance()
}
is AsyncResult.Pending -> {
Profile.getDefaultInstance()
}
}
}

private fun logHomeActivityEvent() {
analyticsController.logImportantEvent(
oppiaLogger.createOpenHomeContext(),
profileId
)
}

private fun handleBackPress(profileType: ProfileType) {
onBackPressedCallback?.remove()

onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
exitProfileListener.exitProfile(profileType)
// The dispatcher can hold a reference to the host
// so we need to null it out to prevent memory leaks.
this.remove()
onBackPressedCallback = null
}
}

onBackPressedCallback?.let { callback ->
activity.onBackPressedDispatcher.addCallback(fragment, callback)
}
}
}

/** Adds a grid of items to a LazyListScope with specified arrangement and item content. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.oppia.android.app.fragment.FragmentComponentImpl
import org.oppia.android.app.fragment.InjectableDialogFragment
import org.oppia.android.app.model.ExitProfileDialogArguments
import org.oppia.android.app.model.HighlightItem
import org.oppia.android.app.model.ProfileType
import org.oppia.android.app.profile.ProfileChooserActivity
import org.oppia.android.util.extensions.getProto
import org.oppia.android.util.extensions.putProto
Expand Down Expand Up @@ -63,18 +64,25 @@ class ExitProfileDialogFragment : InjectableDialogFragment() {
else -> false
}

val soleLearnerProfile = exitProfileDialogArguments.profileType == ProfileType.SOLE_LEARNER

val alertDialog = AlertDialog
.Builder(ContextThemeWrapper(activity as Context, R.style.OppiaAlertDialogTheme))
.setMessage(R.string.home_activity_back_dialog_message)
.setNegativeButton(R.string.home_activity_back_dialog_cancel) { dialog, _ ->
dialog.dismiss()
}
.setPositiveButton(R.string.home_activity_back_dialog_exit) { _, _ ->
val intent = ProfileChooserActivity.createProfileChooserActivity(activity!!)
if (!restoreLastCheckedItem) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
if (soleLearnerProfile) {
requireActivity().finish()
} else {
val intent = ProfileChooserActivity.createProfileChooserActivity(requireActivity())
if (!restoreLastCheckedItem) {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
requireActivity().startActivity(intent)
requireActivity().finish()
}
activity!!.startActivity(intent)
}
.create()
alertDialog.setCanceledOnTouchOutside(false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.oppia.android.app.home

import org.oppia.android.app.model.ProfileType

/** Listener for when a user wishes to exit their profile. */
interface ExitProfileListener {
/**
* Called when back press is clicked on the HomeScreen.
*
* Routing behaviour may change based on [ProfileType]
*/
fun exitProfile(profileType: ProfileType)
}
Loading

0 comments on commit 2eeec89

Please sign in to comment.