From 95699f922321f49a3503783187a14ad1cef0d5d3 Mon Sep 17 00:00:00 2001 From: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com> Date: Sat, 19 Oct 2024 00:23:02 +0300 Subject: [PATCH] Fix Part of #4938: Language Selection Config and New Profile Creation Flow (#5457) ## Explanation Fixes Part of #4938: Modifies the profile creation flow and sets the app/audio language selection during onboarding. ### Default Profile Creation A default empty profile is created when the Onboarding screen is opened for the first time. This is necessary to provide a ProfileId to use when saving the selected app language. Because this will be the first profile on the app in onboarding v2, it is an admin. Provisions have been made so that, should the user exit the app before completing the onboarding flow, this profile will be fetched, to prevent multiple profile creation. ### App Language Selection The language selector will be shown to the user on initial app launch, or if profile onboarding is not complete. - There will be a pre-filled language option based on the locale of the device when the app is installed. If the locale is unsupported, English will be the default selection. - A user can select any preferred supported app language from the dropdown list. - The existing language functionality/behavior will be retained. Tests have been added to verify these requirements, and efforts have been made to ensure language selection persists on configuration change. I noticed during testing that failure to do this resulted in an unpleasant UX. ### Profile Nickname and Picture The "Create profile Screen" has been repurposed to update the default profile instead, providing the remaining profile properties(ProfileType, name, avatar) Checks have been added to check for profile creation errors, consistent with the legacy flow. A new function has been added to the ProfileManagementController to allow for batch update of these fields, and corresponding tests have been added. ### Audio Language Selection The final step of onboarding is the audio language selection, and there will be a pre-filled language selectionas follows: - Selected app language(from first onboarding screen) if available as audio language. (Audio language need not be completed) - Otherwise (if available as audio language), audio language of the administrator account -- this will be added downstream in M3, as it only impacts additional learners. - Otherwise (if available as audio language), device language. - Else, English. There have been some incidental changes in AudioLanguageFragment and OptionsFragment, and their related tests due to sharing of the classes between the existing screens and the new screens. ### ProfileTestHelper.kt A new function, and related tests, have been added to create a default profile to be used in tests. ## Essential Checklist - [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)). ## For UI-specific PRs only The screen recordings are in [this drive location](https://drive.google.com/drive/folders/1CXTAALPgpCKfekOQKjRFTb5G2fPgWkRt?usp=sharing), since github does not support webm. --------- Co-authored-by: Ben Henning --- app/src/main/AndroidManifest.xml | 3 + .../TextInputLayoutBindingAdapters.java | 42 +++ .../AudioLanguageFragmentPresenter.kt | 130 ++++++- .../app/onboarding/CreateProfileActivity.kt | 11 +- .../CreateProfileActivityPresenter.kt | 21 +- .../app/onboarding/CreateProfileFragment.kt | 22 +- .../CreateProfileFragmentPresenter.kt | 156 ++++++++- .../app/onboarding/CreateProfileViewModel.kt | 5 +- .../android/app/onboarding/IntroActivity.kt | 31 +- .../app/onboarding/IntroActivityPresenter.kt | 20 +- .../android/app/onboarding/IntroFragment.kt | 20 +- .../app/onboarding/IntroFragmentPresenter.kt | 4 + .../OnboardingAppLanguageViewModel.kt | 28 ++ .../app/onboarding/OnboardingFragment.kt | 9 +- .../onboarding/OnboardingFragmentPresenter.kt | 240 ++++++++++++- .../OnboardingProfileTypeActivity.kt | 5 +- .../OnboardingProfileTypeActivityPresenter.kt | 10 +- .../OnboardingProfileTypeFragment.kt | 6 +- .../OnboardingProfileTypeFragmentPresenter.kt | 24 +- .../app/options/AudioLanguageActivity.kt | 5 +- .../options/AudioLanguageActivityPresenter.kt | 5 +- .../app/options/AudioLanguageFragment.kt | 27 +- .../AudioLanguageSelectionViewModel.kt | 118 ++++++- .../android/app/options/OptionsActivity.kt | 18 +- .../app/options/OptionsActivityPresenter.kt | 5 +- .../player/audio/AudioFragmentPresenter.kt | 9 +- .../ColorBindingAdaptersTestActivity.kt | 2 +- ...tInputLayoutBindingAdaptersTestActivity.kt | 26 ++ ...tInputLayoutBindingAdaptersTestFragment.kt | 30 ++ app/src/main/res/drawable/learner_otter.xml | 3 +- app/src/main/res/drawable/otter.xml | 3 +- .../res/drawable/parent_teacher_otter.xml | 3 +- .../audio_language_selection_fragment.xml | 11 +- .../layout-land/create_profile_fragment.xml | 2 +- ...arding_app_language_selection_fragment.xml | 1 + .../audio_language_selection_fragment.xml | 11 +- .../create_profile_fragment.xml | 2 +- ...arding_app_language_selection_fragment.xml | 1 + .../audio_language_selection_fragment.xml | 11 +- .../create_profile_fragment.xml | 2 +- ...arding_app_language_selection_fragment.xml | 1 + .../audio_language_selection_fragment.xml | 11 +- .../res/layout/create_profile_fragment.xml | 2 +- ...arding_app_language_selection_fragment.xml | 1 + ..._layout_binding_adapters_test_activity.xml | 6 + ..._layout_binding_adapters_test_fragment.xml | 21 ++ app/src/main/res/values/strings.xml | 2 + .../databinding/ColorBindingAdaptersTest.kt | 2 +- .../TextInputLayoutBindingAdaptersTest.kt | 239 +++++++++++++ .../onboarding/CreateProfileFragmentTest.kt | 230 +++++++++++-- .../app/onboarding/IntroActivityTest.kt | 12 +- .../app/onboarding/IntroFragmentTest.kt | 52 +-- .../app/onboarding/OnboardingFragmentTest.kt | 323 +++++++++++++++++- .../OnboardingProfileTypeFragmentTest.kt | 25 +- .../app/options/AudioLanguageFragmentTest.kt | 114 +++++-- .../profile/ProfileManagementController.kt | 129 ++++++- .../ProfileManagementControllerTest.kt | 187 +++++++++- model/src/main/proto/arguments.proto | 27 ++ model/src/main/proto/profile.proto | 18 + .../accessibility_label_exemptions.textproto | 1 + .../file_content_validation_checks.textproto | 1 + scripts/assets/test_file_exemptions.textproto | 12 + .../testing/espresso/EditTextInputAction.kt | 2 +- .../testing/profile/ProfileTestHelper.kt | 10 + .../testing/profile/ProfileTestHelperTest.kt | 12 + 65 files changed, 2270 insertions(+), 252 deletions(-) create mode 100644 app/src/main/java/org/oppia/android/app/onboarding/OnboardingAppLanguageViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity.kt create mode 100644 app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestFragment.kt create mode 100644 app/src/main/res/layout/text_input_layout_binding_adapters_test_activity.xml create mode 100644 app/src/main/res/layout/text_input_layout_binding_adapters_test_fragment.xml create mode 100644 app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f036a892fbf..7dfcc70bd01 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -348,6 +348,9 @@ android:name=".app.onboarding.IntroActivity" android:label="@string/onboarding_learner_intro_activity_title" android:theme="@style/OppiaThemeWithoutActionBar" /> + /** * Returns a newly inflated view to render the fragment with an evaluated audio language as the @@ -29,9 +47,10 @@ class AudioLanguageFragmentPresenter @Inject constructor( */ fun handleCreateView( inflater: LayoutInflater, - container: ViewGroup? + container: ViewGroup?, + profileId: ProfileId, + outState: Bundle? ): View { - // Hide toolbar as it's not needed in this layout. The toolbar is created by a shared activity // and is required in OptionsFragment. activity.findViewById(R.id.reading_list_app_bar_layout).visibility = View.GONE @@ -41,33 +60,110 @@ class AudioLanguageFragmentPresenter @Inject constructor( container, /* attachToRoot= */ false ) - binding.lifecycleOwner = fragment + + val savedSelectedLanguage = outState?.getProto( + FRAGMENT_SAVED_STATE_KEY, + AudioLanguageFragmentStateBundle.getDefaultInstance() + )?.selectedLanguage + + binding.apply { + lifecycleOwner = fragment + viewModel = audioLanguageSelectionViewModel + } + + audioLanguageSelectionViewModel.updateProfileId(profileId) + + savedSelectedLanguage?.let { + if (it != OppiaLanguage.LANGUAGE_UNSPECIFIED) { + setSelectedLanguage(it) + } else { + observePreselectedLanguage() + } + } ?: observePreselectedLanguage() binding.audioLanguageText.text = appLanguageResourceHandler.getStringInLocaleWithWrapping( R.string.audio_language_fragment_text, appLanguageResourceHandler.getStringInLocale(R.string.app_name) ) - binding.onboardingNavigationBack.setOnClickListener { - activity.finish() - } + binding.onboardingNavigationBack.setOnClickListener { activity.finish() } - val adapter = ArrayAdapter( - fragment.requireContext(), - R.layout.onboarding_language_dropdown_item, - R.id.onboarding_language_text_view, - audioLanguageSelectionViewModel.availableAudioLanguages + audioLanguageSelectionViewModel.supportedOppiaLanguagesLiveData.observe( + fragment, + { languages -> + supportedLanguages = languages + val adapter = ArrayAdapter( + fragment.requireContext(), + R.layout.onboarding_language_dropdown_item, + R.id.onboarding_language_text_view, + languages.map { appLanguageResourceHandler.computeLocalizedDisplayName(it) } + ) + binding.audioLanguageDropdownList.setAdapter(adapter) + } ) binding.audioLanguageDropdownList.apply { - setAdapter(adapter) - setText( - audioLanguageSelectionViewModel.defaultLanguageSelection, - false - ) setRawInputType(EditorInfo.TYPE_NULL) + + onItemClickListener = + AdapterView.OnItemClickListener { _, _, position, _ -> + val selectedItem = adapter.getItem(position) as? String + selectedItem?.let { + selectedLanguage = supportedLanguages.associateBy { oppiaLanguage -> + appLanguageResourceHandler.computeLocalizedDisplayName(oppiaLanguage) + }[it] ?: OppiaLanguage.ENGLISH + } + } + } + + binding.onboardingNavigationContinue.setOnClickListener { + updateSelectedAudioLanguage(selectedLanguage, profileId).also { + val intent = HomeActivity.createHomeActivity(fragment.requireContext(), profileId) + fragment.startActivity(intent) + // Finish this activity as well as all activities immediately below it in the current + // task so that the user cannot navigate back to the onboarding flow by pressing the + // back button once onboarding is complete + fragment.activity?.finishAffinity() + } } return binding.root } + + private fun observePreselectedLanguage() { + audioLanguageSelectionViewModel.languagePreselectionLiveData.observe( + fragment, + { selectedLanguage -> setSelectedLanguage(selectedLanguage) } + ) + } + + private fun setSelectedLanguage(selectedLanguage: OppiaLanguage) { + this.selectedLanguage = selectedLanguage + audioLanguageSelectionViewModel.selectedAudioLanguage.set(selectedLanguage) + } + + private fun updateSelectedAudioLanguage(selectedLanguage: OppiaLanguage, profileId: ProfileId) { + val audioLanguageSelection = + AudioTranslationLanguageSelection.newBuilder().setSelectedLanguage(selectedLanguage).build() + translationController.updateAudioTranslationContentLanguage(profileId, audioLanguageSelection) + .toLiveData().observe(fragment) { + when (it) { + is AsyncResult.Failure -> + oppiaLogger.e( + "AudioLanguageFragment", + "Failed to set the selected language.", + it.error + ) + else -> {} // Do nothing. + } + } + } + + /** Save the current dropdown selection to be retrieved on configuration change. */ + fun handleSavedState(outState: Bundle) { + outState.putProto( + FRAGMENT_SAVED_STATE_KEY, + AudioLanguageFragmentStateBundle.newBuilder().setSelectedLanguage(selectedLanguage).build() + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivity.kt b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivity.kt index 7a0fcb956e1..44cb30b2c92 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivity.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivity.kt @@ -5,8 +5,11 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity +import org.oppia.android.app.model.CreateProfileActivityParams import org.oppia.android.app.model.ScreenName.CREATE_PROFILE_ACTIVITY +import org.oppia.android.util.extensions.getProtoExtra import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** Activity for displaying a new learner profile creation flow. */ @@ -18,7 +21,13 @@ class CreateProfileActivity : InjectableAutoLocalizedAppCompatActivity() { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - learnerProfileActivityPresenter.handleOnCreate() + val profileId = intent.extractCurrentUserProfileId() + val profileType = intent.getProtoExtra( + CREATE_PROFILE_PARAMS_KEY, + CreateProfileActivityParams.getDefaultInstance() + ).profileType + + learnerProfileActivityPresenter.handleOnCreate(profileId, profileType) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivityPresenter.kt index 2fcba3da31e..86f4d548a49 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileActivityPresenter.kt @@ -1,11 +1,20 @@ package org.oppia.android.app.onboarding +import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import org.oppia.android.R +import org.oppia.android.app.model.CreateProfileFragmentArguments +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ProfileType import org.oppia.android.databinding.CreateProfileActivityBinding +import org.oppia.android.util.extensions.putProto +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject +/** Argument key for [CreateProfileFragment] arguments. */ +const val CREATE_PROFILE_FRAGMENT_ARGS = "CreateProfileFragment.args" + private const val TAG_CREATE_PROFILE_ACTIVITY_FRAGMENT = "TAG_CREATE_PROFILE_ACTIVITY_FRAGMENT" /** Presenter for [CreateProfileActivity]. */ @@ -15,7 +24,7 @@ class CreateProfileActivityPresenter @Inject constructor( private lateinit var binding: CreateProfileActivityBinding /** Handle creation and binding of the CreateProfileActivity layout. */ - fun handleOnCreate() { + fun handleOnCreate(profileId: ProfileId, profileType: ProfileType) { binding = DataBindingUtil.setContentView(activity, R.layout.create_profile_activity) binding.apply { lifecycleOwner = activity @@ -23,6 +32,16 @@ class CreateProfileActivityPresenter @Inject constructor( if (getNewLearnerProfileFragment() == null) { val createLearnerProfileFragment = CreateProfileFragment() + + val args = Bundle().apply { + val fragmentArgs = + CreateProfileFragmentArguments.newBuilder().setProfileType(profileType).build() + putProto(CREATE_PROFILE_FRAGMENT_ARGS, fragmentArgs) + decorateWithUserProfileId(profileId) + } + + createLearnerProfileFragment.arguments = args + activity.supportFragmentManager.beginTransaction().add( R.id.profile_fragment_placeholder, createLearnerProfileFragment, diff --git a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragment.kt b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragment.kt index ac09fc5fbd9..7e308004cf1 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragment.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragment.kt @@ -9,6 +9,9 @@ import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.app.model.CreateProfileFragmentArguments +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** Fragment for displaying a new learner profile creation flow. */ @@ -33,6 +36,23 @@ class CreateProfileFragment : InjectableFragment() { createProfileFragmentPresenter.handleOnActivityResult(result.data) } } - return createProfileFragmentPresenter.handleCreateView(inflater, container) + + val profileId = checkNotNull(arguments?.extractCurrentUserProfileId()) { + "Expected CreateProfileFragment to have a profileId argument." + } + val profileType = checkNotNull( + arguments?.getProto( + CREATE_PROFILE_FRAGMENT_ARGS, CreateProfileFragmentArguments.getDefaultInstance() + )?.profileType + ) { + "Expected CreateProfileFragment to have a profileType argument." + } + + return createProfileFragmentPresenter.handleCreateView( + inflater, + container, + profileId, + profileType + ) } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt index 10193abe3ec..44c1aad1746 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.onboarding import android.content.Intent import android.graphics.PorterDuff +import android.net.Uri import android.provider.MediaStore import android.text.Editable import android.text.TextWatcher @@ -11,13 +12,24 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.IntroActivityParams +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ProfileType +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.CreateProfileFragmentBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.parser.image.ImageLoader import org.oppia.android.util.parser.image.ImageViewTarget +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject /** Presenter for [CreateProfileFragment]. */ @@ -25,23 +37,37 @@ import javax.inject.Inject class CreateProfileFragmentPresenter @Inject constructor( private val fragment: Fragment, private val activity: AppCompatActivity, + private val imageLoader: ImageLoader, private val createProfileViewModel: CreateProfileViewModel, - private val imageLoader: ImageLoader + private val profileManagementController: ProfileManagementController, + private val oppiaLogger: OppiaLogger, + private val appLanguageResourceHandler: AppLanguageResourceHandler ) { private lateinit var binding: CreateProfileFragmentBinding private lateinit var uploadImageView: ImageView private lateinit var selectedImage: String + private lateinit var profileId: ProfileId + private lateinit var profileType: ProfileType + private var selectedImageUri: Uri? = null /** Launcher for picking an image from device gallery. */ lateinit var activityResultLauncher: ActivityResultLauncher /** Initialize layout bindings. */ - fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View { + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + profileId: ProfileId, + profileType: ProfileType + ): View { binding = CreateProfileFragmentBinding.inflate( inflater, container, /* attachToRoot= */ false ) + this.profileId = profileId + this.profileType = profileType + binding.let { it.lifecycleOwner = fragment it.viewModel = createProfileViewModel @@ -68,11 +94,8 @@ class CreateProfileFragmentPresenter @Inject constructor( binding.onboardingNavigationContinue.setOnClickListener { val nickname = binding.createProfileNicknameEdittext.text.toString().trim() - createProfileViewModel.hasErrorMessage.set(nickname.isBlank()) - - if (createProfileViewModel.hasErrorMessage.get() != true) { - val intent = IntroActivity.createIntroActivity(activity, nickname) - fragment.startActivity(intent) + if (!checkNicknameAndUpdateError(nickname)) { + updateProfileDetails(nickname) } } @@ -89,10 +112,22 @@ class CreateProfileFragmentPresenter @Inject constructor( return binding.root } + private fun checkNicknameAndUpdateError(nickname: String): Boolean { + val hasError = nickname.isBlank() + createProfileViewModel.hasErrorMessage.set(hasError) + createProfileViewModel.errorMessage.set( + appLanguageResourceHandler.getStringInLocale( + R.string.create_profile_activity_nickname_error + ) + ) + return hasError + } + /** Receive the result of image upload and load it into the image view. */ fun handleOnActivityResult(intent: Intent?) { intent?.let { binding.createProfilePicturePrompt.visibility = View.GONE + selectedImageUri = intent.data selectedImage = checkNotNull(intent.data.toString()) { "Could not find the selected image." } imageLoader.loadBitmap( @@ -107,19 +142,108 @@ class CreateProfileFragmentPresenter @Inject constructor( binding.onboardingNavigationBack.setOnClickListener { activity.finish() } binding.createProfileEditPictureIcon.setOnClickListener { - activityResultLauncher.launch( - galleryIntent - ) + activityResultLauncher.launch(galleryIntent) } binding.createProfilePicturePrompt.setOnClickListener { - activityResultLauncher.launch( - galleryIntent - ) + activityResultLauncher.launch(galleryIntent) } binding.createProfileUserImageView.setOnClickListener { - activityResultLauncher.launch( - galleryIntent - ) + activityResultLauncher.launch(galleryIntent) } } + + private fun updateProfileDetails(profileName: String) { + profileManagementController.updateNewProfileDetails( + profileId = profileId, + profileType = profileType, + avatarImagePath = selectedImageUri, + colorRgb = selectUniqueRandomColor(), + newName = profileName, + isAdmin = true + ).toLiveData().observe( + fragment, + { result -> + when (result) { + is AsyncResult.Success -> { + createProfileViewModel.hasErrorMessage.set(false) + + val params = IntroActivityParams.newBuilder() + .setProfileNickname(profileName) + .build() + + val intent = + IntroActivity.createIntroActivity(activity).apply { + putProtoExtra(IntroActivity.PARAMS_KEY, params) + decorateWithUserProfileId(profileId) + } + + fragment.startActivity(intent) + } + is AsyncResult.Failure -> { + createProfileViewModel.hasErrorMessage.set(true) + + val errorMessage = when (result.error) { + is ProfileManagementController.ProfileNameOnlyLettersException -> + appLanguageResourceHandler.getStringInLocale( + R.string.add_profile_error_name_only_letters + ) + is ProfileManagementController.UnknownProfileTypeException -> + appLanguageResourceHandler.getStringInLocale( + R.string.add_profile_error_missing_profile_type + ) + else -> { + appLanguageResourceHandler.getStringInLocale( + R.string.add_profile_default_error_message + ) + } + } + + createProfileViewModel.errorMessage.set(errorMessage) + + oppiaLogger.e( + "CreateProfileFragment", + "Failed to update profile details.", + result.error + ) + } + is AsyncResult.Pending -> {} + } + } + ) + } + + /** Randomly selects a color for the new profile that is not already in use. */ + private fun selectUniqueRandomColor(): Int { + return ContextCompat.getColor(fragment.requireContext(), COLORS_LIST.random()) + } + + private companion object { + private val COLORS_LIST = listOf( + R.color.component_color_avatar_background_1_color, + R.color.component_color_avatar_background_2_color, + R.color.component_color_avatar_background_3_color, + R.color.component_color_avatar_background_4_color, + R.color.component_color_avatar_background_5_color, + R.color.component_color_avatar_background_6_color, + R.color.component_color_avatar_background_7_color, + R.color.component_color_avatar_background_8_color, + R.color.component_color_avatar_background_9_color, + R.color.component_color_avatar_background_10_color, + R.color.component_color_avatar_background_11_color, + R.color.component_color_avatar_background_12_color, + R.color.component_color_avatar_background_13_color, + R.color.component_color_avatar_background_14_color, + R.color.component_color_avatar_background_15_color, + R.color.component_color_avatar_background_16_color, + R.color.component_color_avatar_background_17_color, + R.color.component_color_avatar_background_18_color, + R.color.component_color_avatar_background_19_color, + R.color.component_color_avatar_background_20_color, + R.color.component_color_avatar_background_21_color, + R.color.component_color_avatar_background_22_color, + R.color.component_color_avatar_background_23_color, + R.color.component_color_avatar_background_24_color, + R.color.component_color_avatar_background_25_color + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileViewModel.kt b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileViewModel.kt index e6ef763f23c..fa5deceb2da 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileViewModel.kt @@ -9,6 +9,9 @@ import javax.inject.Inject @FragmentScope class CreateProfileViewModel @Inject constructor() : ObservableViewModel() { - /** ObservableField that tracks whether creating a nickname has triggered an error condition. */ + /** [ObservableField] that tracks whether creating a profile has triggered an error condition. */ val hasErrorMessage = ObservableField(false) + + /** [ObservableField] that tracks the error message to be displayed to the user. */ + val errorMessage = ObservableField("") } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt index 9ca2991707d..17daf8c3ec4 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt @@ -8,8 +8,8 @@ import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity import org.oppia.android.app.model.IntroActivityParams import org.oppia.android.app.model.ScreenName.INTRO_ACTIVITY import org.oppia.android.util.extensions.getProtoExtra -import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** The activity for showing the learner welcome screen. */ @@ -17,43 +17,30 @@ class IntroActivity : InjectableAutoLocalizedAppCompatActivity() { @Inject lateinit var onboardingLearnerIntroActivityPresenter: IntroActivityPresenter - private lateinit var profileNickname: String - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - val params = intent.extractParams() - this.profileNickname = params.profileNickname + val profileNickname = + intent.getProtoExtra(PARAMS_KEY, IntroActivityParams.getDefaultInstance()).profileNickname + + val profileId = intent.extractCurrentUserProfileId() - onboardingLearnerIntroActivityPresenter.handleOnCreate(profileNickname) + onboardingLearnerIntroActivityPresenter.handleOnCreate(profileNickname, profileId) } companion object { - private const val PARAMS_KEY = "OnboardingIntroActivity.params" + /** Argument key for [IntroActivity]'s intent parameters. */ + const val PARAMS_KEY = "OnboardingIntroActivity.params" /** * A convenience function for creating a new [OnboardingLearnerIntroActivity] intent by prefilling * common params needed by the activity. */ - fun createIntroActivity(context: Context, profileNickname: String): Intent { - val params = IntroActivityParams.newBuilder() - .setProfileNickname(profileNickname) - .build() - return createOnboardingLearnerIntroActivity(context, params) - } - - private fun createOnboardingLearnerIntroActivity( - context: Context, - params: IntroActivityParams - ): Intent { + fun createIntroActivity(context: Context): Intent { return Intent(context, IntroActivity::class.java).apply { - putProtoExtra(PARAMS_KEY, params) decorateWithScreenName(INTRO_ACTIVITY) } } - - private fun Intent.extractParams() = - getProtoExtra(PARAMS_KEY, IntroActivityParams.getDefaultInstance()) } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt index 7615fbc1c75..52bd6058eb3 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt @@ -5,13 +5,17 @@ import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.model.IntroFragmentArguments +import org.oppia.android.app.model.ProfileId import org.oppia.android.databinding.IntroActivityBinding +import org.oppia.android.util.extensions.putProto +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject private const val TAG_LEARNER_INTRO_FRAGMENT = "TAG_INTRO_FRAGMENT" -/** Argument key for bundling the profileId. */ -const val PROFILE_NICKNAME_ARGUMENT_KEY = "profile_nickname" +/** Argument key for bundling the profile nickname. */ +const val PROFILE_NICKNAME_ARGUMENT_KEY = "IntroFragment.Arguments" /** The Presenter for [IntroActivity]. */ @ActivityScope @@ -21,15 +25,21 @@ class IntroActivityPresenter @Inject constructor( private lateinit var binding: IntroActivityBinding /** Handle creation and binding of the [IntroActivity] layout. */ - fun handleOnCreate(profileNickname: String) { + fun handleOnCreate(profileNickname: String, profileId: ProfileId) { binding = DataBindingUtil.setContentView(activity, R.layout.intro_activity) binding.lifecycleOwner = activity if (getIntroFragment() == null) { val introFragment = IntroFragment() - val args = Bundle() - args.putString(PROFILE_NICKNAME_ARGUMENT_KEY, profileNickname) + val argumentsProto = + IntroFragmentArguments.newBuilder().setProfileNickname(profileNickname).build() + + val args = Bundle().apply { + decorateWithUserProfileId(profileId) + putProto(PROFILE_NICKNAME_ARGUMENT_KEY, argumentsProto) + } + introFragment.arguments = args activity.supportFragmentManager.beginTransaction().add( diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt index 0c954d2df85..6c3e40bc529 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt @@ -7,7 +7,9 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment -import org.oppia.android.util.extensions.getStringFromBundle +import org.oppia.android.app.model.IntroFragmentArguments +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** Fragment that contains the introduction message for new learners. */ @@ -26,13 +28,25 @@ class IntroFragment : InjectableFragment() { savedInstanceState: Bundle? ): View? { val profileNickname = - checkNotNull(arguments?.getStringFromBundle(PROFILE_NICKNAME_ARGUMENT_KEY)) { + checkNotNull( + arguments?.getProto( + PROFILE_NICKNAME_ARGUMENT_KEY, + IntroFragmentArguments.getDefaultInstance() + ) + ) { "Expected profileNickname to be included in the arguments for IntroFragment." + }.profileNickname + + val profileId = + checkNotNull(arguments?.extractCurrentUserProfileId()) { + "Expected profileId to be included in the arguments for IntroFragment." } + return introFragmentPresenter.handleCreateView( inflater, container, - profileNickname + profileNickname, + profileId ) } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt index 50fa51300c7..ac7739d5ad3 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt @@ -7,9 +7,11 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.model.AudioLanguage +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.options.AudioLanguageActivity import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.LearnerIntroFragmentBinding +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject /** The presenter for [IntroFragment]. */ @@ -25,6 +27,7 @@ class IntroFragmentPresenter @Inject constructor( inflater: LayoutInflater, container: ViewGroup?, profileNickname: String, + profileId: ProfileId ): View { binding = LearnerIntroFragmentBinding.inflate( inflater, @@ -51,6 +54,7 @@ class IntroFragmentPresenter @Inject constructor( fragment.requireContext(), AudioLanguage.ENGLISH_AUDIO_LANGUAGE ) + intent.decorateWithUserProfileId(profileId) fragment.startActivity(intent) } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingAppLanguageViewModel.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingAppLanguageViewModel.kt new file mode 100644 index 00000000000..d792861aab3 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingAppLanguageViewModel.kt @@ -0,0 +1,28 @@ +package org.oppia.android.app.onboarding + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.viewmodel.ObservableViewModel +import javax.inject.Inject + +/** ViewModel for managing language selection in [OnboardingFragment]. */ +class OnboardingAppLanguageViewModel @Inject constructor() : ObservableViewModel() { + /** The selected app language displayed in the language dropdown. */ + val languageSelectionLiveData: LiveData get() = _languageSelectionLiveData + private val _languageSelectionLiveData = MutableLiveData() + + /** Get the list of app supported languages to be displayed in the language dropdown. */ + val supportedAppLanguagesList: LiveData> get() = _supportedAppLanguagesList + private val _supportedAppLanguagesList = MutableLiveData>() + + /** Sets the app language selection. */ + fun setSelectedLanguageLivedata(language: OppiaLanguage) { + _languageSelectionLiveData.value = language + } + + /** Sets the list of app supported languages to be displayed in the language dropdown. */ + fun setSupportedAppLanguages(languageList: List) { + _supportedAppLanguagesList.value = languageList + } +} diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt index 677a4a08515..5c207579761 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt @@ -34,9 +34,16 @@ class OnboardingFragment : InjectableFragment() { savedInstanceState: Bundle? ): View? { return if (enableOnboardingFlowV2.value) { - onboardingFragmentPresenter.handleCreateView(inflater, container) + onboardingFragmentPresenter.handleCreateView(inflater, container, savedInstanceState) } else { onboardingFragmentPresenterV1.handleCreateView(inflater, container) } } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (enableOnboardingFlowV2.value) { + onboardingFragmentPresenter.saveToSavedInstanceState(outState) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt index 79bd8dc270c..332fd930117 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt @@ -1,33 +1,78 @@ package org.oppia.android.app.onboarding +import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.AdapterView +import android.widget.ArrayAdapter import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.AppLanguageSelection +import org.oppia.android.app.model.OnboardingFragmentStateBundle +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.Profile +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.OnboardingAppLanguageSelectionFragmentBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController +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.extensions.getProto +import org.oppia.android.util.extensions.putProto +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject +private const val ONBOARDING_FRAGMENT_SAVED_STATE_KEY = "OnboardingFragment.saved_state" + /** The presenter for [OnboardingFragment]. */ @FragmentScope class OnboardingFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, - private val appLanguageResourceHandler: AppLanguageResourceHandler + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val profileManagementController: ProfileManagementController, + private val oppiaLogger: OppiaLogger, + private val translationController: TranslationController, + private val onboardingAppLanguageViewModel: OnboardingAppLanguageViewModel ) { private lateinit var binding: OnboardingAppLanguageSelectionFragmentBinding + private var profileId: ProfileId = ProfileId.getDefaultInstance() + private lateinit var selectedLanguage: OppiaLanguage + private lateinit var supportedLanguages: List /** Handle creation and binding of the [OnboardingFragment] layout. */ - fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View { + fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?, outState: Bundle?): View { binding = OnboardingAppLanguageSelectionFragmentBinding.inflate( inflater, container, /* attachToRoot= */ false ) + val savedSelectedLanguage = outState?.getProto( + ONBOARDING_FRAGMENT_SAVED_STATE_KEY, + OnboardingFragmentStateBundle.getDefaultInstance() + )?.selectedLanguage + + if (savedSelectedLanguage != null) { + selectedLanguage = savedSelectedLanguage + onboardingAppLanguageViewModel.setSelectedLanguageLivedata(savedSelectedLanguage) + } else { + initializeSelectedLanguageToSystemLanguage() + } + + retrieveSupportedLanguages() + + subscribeToGetProfileList() + binding.apply { lifecycleOwner = fragment @@ -36,13 +81,198 @@ class OnboardingFragmentPresenter @Inject constructor( appLanguageResourceHandler.getStringInLocale(R.string.app_name) ) + onboardingAppLanguageViewModel.supportedAppLanguagesList.observe( + fragment, + { languagesList -> + supportedLanguages = languagesList + val adapter = ArrayAdapter( + fragment.requireContext(), + R.layout.onboarding_language_dropdown_item, + R.id.onboarding_language_text_view, + languagesList.map { appLanguageResourceHandler.computeLocalizedDisplayName(it) } + ) + onboardingLanguageDropdown.setAdapter(adapter) + } + ) + + onboardingAppLanguageViewModel.languageSelectionLiveData.observe( + fragment, + { language -> + selectedLanguage = language + onboardingLanguageDropdown.setText( + appLanguageResourceHandler.computeLocalizedDisplayName( + language + ), + false + ) + } + ) + + onboardingLanguageDropdown.apply { + setRawInputType(EditorInfo.TYPE_NULL) + + onItemClickListener = + AdapterView.OnItemClickListener { _, _, position, _ -> + adapter.getItem(position).let { selectedItem -> + selectedItem?.let { + selectedLanguage = supportedLanguages.associateBy { oppiaLanguage -> + appLanguageResourceHandler.computeLocalizedDisplayName(oppiaLanguage) + }[it] ?: OppiaLanguage.ENGLISH + onboardingAppLanguageViewModel.setSelectedLanguageLivedata(selectedLanguage) + } + } + } + } + onboardingLanguageLetsGoButton.setOnClickListener { - val intent = - OnboardingProfileTypeActivity.createOnboardingProfileTypeActivityIntent(activity) - fragment.startActivity(intent) + updateSelectedLanguage(selectedLanguage).also { + val intent = + OnboardingProfileTypeActivity.createOnboardingProfileTypeActivityIntent(activity) + intent.decorateWithUserProfileId(profileId) + fragment.startActivity(intent) + } } } return binding.root } + + private val existingProfiles: LiveData> by lazy { + Transformations.map( + profileManagementController.getProfiles().toLiveData(), + ::processGetProfilesResult + ) + } + + /** Save the current dropdown selection to be retrieved on configuration change. */ + fun saveToSavedInstanceState(outState: Bundle) { + outState.putProto( + ONBOARDING_FRAGMENT_SAVED_STATE_KEY, + OnboardingFragmentStateBundle.newBuilder().setSelectedLanguage(selectedLanguage).build() + ) + } + + private fun updateSelectedLanguage(selectedLanguage: OppiaLanguage) { + val selection = AppLanguageSelection.newBuilder().setSelectedLanguage(selectedLanguage).build() + translationController.updateAppLanguage(profileId, selection).toLiveData() + .observe( + fragment, + { result -> + when (result) { + is AsyncResult.Failure -> oppiaLogger.e( + "OnboardingFragment", + "Failed to set AppLanguageSelection", + result.error + ) + else -> {} // Do nothing. The user should be able to progress regardless of the result. + } + } + ) + } + + private fun initializeSelectedLanguageToSystemLanguage() { + translationController.getSystemLanguageLocale().toLiveData().observe( + fragment, + { result -> + onboardingAppLanguageViewModel.setSelectedLanguageLivedata( + processSystemLanguageResult(result) + ) + } + ) + } + + private fun processSystemLanguageResult( + result: AsyncResult + ): OppiaLanguage { + return when (result) { + is AsyncResult.Success -> { + result.value.getCurrentLanguage() + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "OnboardingFragment", + "Failed to retrieve system language locale.", + result.error + ) + OppiaLanguage.ENGLISH + } + is AsyncResult.Pending -> OppiaLanguage.ENGLISH + } + } + + private fun retrieveSupportedLanguages() { + translationController.getSupportedAppLanguages().toLiveData().observe( + fragment, + { result -> + when (result) { + is AsyncResult.Success -> { + onboardingAppLanguageViewModel.setSupportedAppLanguages(result.value) + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "OnboardingFragment", + "Failed to retrieve supported language list.", + result.error + ) + } + is AsyncResult.Pending -> {} + } + } + ) + } + + private fun subscribeToGetProfileList() { + existingProfiles.observe( + fragment, + { profilesList -> + if (!profilesList.isNullOrEmpty()) { + profileId = profilesList.first().id + } else { + createDefaultProfile() + } + } + ) + } + + private fun processGetProfilesResult(profilesResult: AsyncResult>): List { + val profileList = when (profilesResult) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "OnboardingFragment", "Failed to retrieve the list of profiles", profilesResult.error + ) + emptyList() + } + is AsyncResult.Pending -> emptyList() + is AsyncResult.Success -> profilesResult.value + } + + return profileList + } + + private fun createDefaultProfile() { + profileManagementController.addProfile( + name = "Admin", // TODO(#4938): Refactor to empty name once proper admin profile creation flow + // is implemented. + pin = "", + avatarImagePath = null, + allowDownloadAccess = true, + colorRgb = -10710042, + isAdmin = true + ).toLiveData() + .observe( + fragment, + { result -> + when (result) { + is AsyncResult.Success -> subscribeToGetProfileList() + is AsyncResult.Failure -> { + oppiaLogger.e( + "OnboardingFragment", "Error creating the default profile", result.error + ) + activity.finish() + } + is AsyncResult.Pending -> {} + } + } + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivity.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivity.kt index 3be8b397e83..223ade63fb8 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivity.kt @@ -7,6 +7,7 @@ import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity import org.oppia.android.app.model.ScreenName.ONBOARDING_PROFILE_TYPE_ACTIVITY import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** The activity for showing the profile type selection screen. */ @@ -18,7 +19,9 @@ class OnboardingProfileTypeActivity : InjectableAutoLocalizedAppCompatActivity() super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - onboardingProfileTypeActivityPresenter.handleOnCreate() + val profileId = intent.extractCurrentUserProfileId() + + onboardingProfileTypeActivityPresenter.handleOnCreate(profileId) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivityPresenter.kt index 48c0792a006..e251658bbae 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeActivityPresenter.kt @@ -1,10 +1,13 @@ package org.oppia.android.app.onboarding +import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.model.ProfileId import org.oppia.android.databinding.OnboardingProfileTypeActivityBinding +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject private const val TAG_PROFILE_TYPE_FRAGMENT = "TAG_PROFILE_TYPE_FRAGMENT" @@ -17,7 +20,7 @@ class OnboardingProfileTypeActivityPresenter @Inject constructor( private lateinit var binding: OnboardingProfileTypeActivityBinding /** Handle creation and binding of the OnboardingProfileTypeActivity layout. */ - fun handleOnCreate() { + fun handleOnCreate(profileId: ProfileId) { binding = DataBindingUtil.setContentView(activity, R.layout.onboarding_profile_type_activity) binding.apply { lifecycleOwner = activity @@ -25,6 +28,11 @@ class OnboardingProfileTypeActivityPresenter @Inject constructor( if (getOnboardingProfileTypeFragment() == null) { val onboardingProfileTypeFragment = OnboardingProfileTypeFragment() + val args = Bundle().apply { + decorateWithUserProfileId(profileId) + } + onboardingProfileTypeFragment.arguments = args + activity.supportFragmentManager.beginTransaction().add( R.id.profile_type_fragment_placeholder, onboardingProfileTypeFragment, diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragment.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragment.kt index 128788b3c4d..a4b594e9e15 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** Fragment that contains the profile type selection flow of the app. */ @@ -24,6 +25,9 @@ class OnboardingProfileTypeFragment : InjectableFragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return onboardingProfileTypeFragmentPresenter.handleCreateView(inflater, container) + val profileId = checkNotNull(arguments?.extractCurrentUserProfileId()) { + "Expected OnboardingProfileTypeFragment to have a profileId argument." + } + return onboardingProfileTypeFragmentPresenter.handleCreateView(inflater, container, profileId) } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentPresenter.kt index 72ae543dd0c..5d8a7734007 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentPresenter.kt @@ -5,10 +5,18 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import org.oppia.android.app.model.CreateProfileActivityParams +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ProfileType import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.databinding.OnboardingProfileTypeFragmentBinding +import org.oppia.android.util.extensions.putProtoExtra +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject +/** Argument key for [CreateProfileActivity] intent parameters. */ +const val CREATE_PROFILE_PARAMS_KEY = "CreateProfileActivity.params" + /** The presenter for [OnboardingProfileTypeFragment]. */ class OnboardingProfileTypeFragmentPresenter @Inject constructor( private val fragment: Fragment, @@ -17,7 +25,11 @@ class OnboardingProfileTypeFragmentPresenter @Inject constructor( private lateinit var binding: OnboardingProfileTypeFragmentBinding /** Handle creation and binding of the OnboardingProfileTypeFragment layout. */ - fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View { + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + profileId: ProfileId + ): View { binding = OnboardingProfileTypeFragmentBinding.inflate( inflater, container, @@ -29,11 +41,21 @@ class OnboardingProfileTypeFragmentPresenter @Inject constructor( profileTypeLearnerNavigationCard.setOnClickListener { val intent = CreateProfileActivity.createProfileActivityIntent(activity) + intent.apply { + decorateWithUserProfileId(profileId) + putProtoExtra( + CREATE_PROFILE_PARAMS_KEY, + CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() + ) + } fragment.startActivity(intent) } profileTypeSupervisorNavigationCard.setOnClickListener { val intent = ProfileChooserActivity.createProfileChooserActivity(activity) + // TODO(#4938): Add profileId and ProfileType to intent extras. fragment.startActivity(intent) } diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt index 7042393f3d4..48b3c1ef4b5 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt @@ -14,6 +14,7 @@ import org.oppia.android.util.extensions.getProtoExtra import org.oppia.android.util.extensions.putProto import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject /** The activity to change the Default Audio language of the app. */ @@ -23,8 +24,10 @@ class AudioLanguageActivity : InjectableAutoLocalizedAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) + val profileId = intent.extractCurrentUserProfileId() audioLanguageActivityPresenter.handleOnCreate( - savedInstanceState?.retrieveLanguageFromSavedState() ?: intent.retrieveLanguageFromParams() + savedInstanceState?.retrieveLanguageFromSavedState() ?: intent.retrieveLanguageFromParams(), + profileId ) } diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivityPresenter.kt index cb33ecf7c0e..fa4e149207d 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivityPresenter.kt @@ -8,6 +8,7 @@ import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.app.model.AudioLanguage import org.oppia.android.app.model.AudioLanguageActivityResultBundle +import org.oppia.android.app.model.ProfileId import org.oppia.android.databinding.AudioLanguageActivityBinding import org.oppia.android.util.extensions.putProtoExtra import javax.inject.Inject @@ -18,7 +19,7 @@ class AudioLanguageActivityPresenter @Inject constructor(private val activity: A private lateinit var audioLanguage: AudioLanguage /** Handles when the activity is first created. */ - fun handleOnCreate(audioLanguage: AudioLanguage) { + fun handleOnCreate(audioLanguage: AudioLanguage, profileId: ProfileId) { this.audioLanguage = audioLanguage val binding: AudioLanguageActivityBinding = @@ -27,7 +28,7 @@ class AudioLanguageActivityPresenter @Inject constructor(private val activity: A finishWithResult() } if (getAudioLanguageFragment() == null) { - val audioLanguageFragment = AudioLanguageFragment.newInstance(audioLanguage) + val audioLanguageFragment = AudioLanguageFragment.newInstance(audioLanguage, profileId) activity.supportFragmentManager.beginTransaction() .add(R.id.audio_language_fragment_container, audioLanguageFragment).commitNow() } diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt index 4cb067f8cc7..06e0e2cac1c 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt @@ -10,11 +10,14 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.AudioLanguage import org.oppia.android.app.model.AudioLanguageFragmentArguments import org.oppia.android.app.model.AudioLanguageFragmentStateBundle +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.onboarding.AudioLanguageFragmentPresenter import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto 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 /** The fragment to change the default audio language of the app. */ @@ -41,9 +44,18 @@ class AudioLanguageFragment : InjectableFragment(), AudioLanguageRadioButtonList checkNotNull( savedInstanceState?.retrieveLanguageFromSavedState() ?: arguments?.retrieveLanguageFromArguments() - ) { "Expected arguments to be passed to AudioLanguageFragment" } + ) { "Expected arguments to be passed to AudioLanguageFragment." } + return if (enableOnboardingFlowV2.value) { - audioLanguageFragmentPresenter.handleCreateView(inflater, container) + val profileId = checkNotNull(arguments?.extractCurrentUserProfileId()) { + "Expected a profileId argument to be passed to AudioLanguageFragment." + } + audioLanguageFragmentPresenter.handleCreateView( + inflater, + container, + profileId, + savedInstanceState + ) } else { audioLanguageFragmentPresenterV1.handleOnCreateView(inflater, container, audioLanguage) } @@ -51,7 +63,9 @@ class AudioLanguageFragment : InjectableFragment(), AudioLanguageRadioButtonList override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - if (!enableOnboardingFlowV2.value) { + if (enableOnboardingFlowV2.value) { + audioLanguageFragmentPresenter.handleSavedState(outState) + } else { val state = AudioLanguageFragmentStateBundle.newBuilder().apply { audioLanguage = audioLanguageFragmentPresenterV1.getLanguageSelected() }.build() @@ -67,19 +81,22 @@ class AudioLanguageFragment : InjectableFragment(), AudioLanguageRadioButtonList companion object { private const val FRAGMENT_ARGUMENTS_KEY = "AudioLanguageFragment.arguments" - private const val FRAGMENT_SAVED_STATE_KEY = "AudioLanguageFragment.saved_state" + + /** Argument key for the [AudioLanguageFragment] saved instance state bundle. */ + const val FRAGMENT_SAVED_STATE_KEY = "AudioLanguageFragment.saved_state" /** * Returns a new [AudioLanguageFragment] corresponding to the specified [AudioLanguage] (as the * initial selection). */ - fun newInstance(audioLanguage: AudioLanguage): AudioLanguageFragment { + fun newInstance(audioLanguage: AudioLanguage, profileId: ProfileId): AudioLanguageFragment { return AudioLanguageFragment().apply { arguments = Bundle().apply { val args = AudioLanguageFragmentArguments.newBuilder().apply { this.audioLanguage = audioLanguage }.build() putProto(FRAGMENT_ARGUMENTS_KEY, args) + decorateWithUserProfileId(profileId) } } } diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageSelectionViewModel.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageSelectionViewModel.kt index 5e25aaabf5d..27b40c88b34 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageSelectionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageSelectionViewModel.kt @@ -1,19 +1,59 @@ package org.oppia.android.app.options +import androidx.databinding.ObservableField import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.AppLanguageSelection import org.oppia.android.app.model.AudioLanguage +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders.Companion.combineWith +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import org.oppia.android.util.locale.OppiaLocale import javax.inject.Inject -/** Language list view model for the recycler view in [AudioLanguageFragment]. */ +private const val PRE_SELECTED_LANGUAGE_PROVIDER_ID = "systemLanguage+appLanguageProvider" + +/** ViewModel for managing language selection in [AudioLanguageFragment]. */ @FragmentScope class AudioLanguageSelectionViewModel @Inject constructor( private val fragment: Fragment, - private val appLanguageResourceHandler: AppLanguageResourceHandler + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val oppiaLogger: OppiaLogger ) : ObservableViewModel() { + private lateinit var profileId: ProfileId + + /** An [ObservableField] to bind the resolved audio language to the dropdown text. */ + val selectedAudioLanguage = ObservableField(OppiaLanguage.LANGUAGE_UNSPECIFIED) + + /** The [LiveData] representing the language to be displayed by default in the dropdown menu. */ + val languagePreselectionLiveData: LiveData by lazy { + Transformations.map(languagePreselectionProvider.toLiveData()) { languageResult -> + return@map when (languageResult) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "AudioLanguageFragment", + "Failed to retrieve language information.", + languageResult.error + ) + OppiaLanguage.LANGUAGE_UNSPECIFIED + } + is AsyncResult.Pending -> OppiaLanguage.LANGUAGE_UNSPECIFIED + is AsyncResult.Success -> languageResult.value + } + } + } + /** The [AudioLanguage] currently selected in the radio button list. */ val selectedLanguage = MutableLiveData() @@ -22,6 +62,67 @@ class AudioLanguageSelectionViewModel @Inject constructor( AudioLanguage.values().filter { it !in IGNORED_AUDIO_LANGUAGES }.map(::createItemViewModel) } + /** Get the list of app supported languages to be displayed in the language dropdown. */ + val availableAudioLanguages: LiveData> get() = _availableAudioLanguages + private val _availableAudioLanguages = MutableLiveData>() + + /** Sets the list of audio languages supported by the app based on [OppiaLanguage]. */ + val supportedOppiaLanguagesLiveData: LiveData> by lazy { + Transformations.map( + translationController.getSupportedAppLanguages().toLiveData() + ) { supportedLanguagesResult -> + return@map when (supportedLanguagesResult) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "AudioLanguageFragment", + "Failed to retrieve supported languages.", + supportedLanguagesResult.error + ) + listOf() + } + is AsyncResult.Pending -> listOf() + is AsyncResult.Success -> supportedLanguagesResult.value + } + } + } + + // TODO(#4938): Update the pre-selection logic to include the admin profile audio language for + // non-sole learners. + private val languagePreselectionProvider: DataProvider by lazy { + appLanguageSelectionProvider.combineWith( + systemLanguageProvider, + PRE_SELECTED_LANGUAGE_PROVIDER_ID + ) { appLanguageSelection: AppLanguageSelection, displayLocale: OppiaLocale.DisplayLocale -> + val appLanguage = appLanguageSelection.selectedLanguage + val systemLanguage = displayLocale.getCurrentLanguage() + computePreselection(appLanguage, systemLanguage) + } + } + + private val appLanguageSelectionProvider: DataProvider by lazy { + translationController.getAppLanguageSelection(profileId) + } + + private val systemLanguageProvider: DataProvider by lazy { + translationController.getSystemLanguageLocale() + } + + /** Receives and sets the current profileId in this viewModel. */ + fun updateProfileId(profileId: ProfileId) { + this.profileId = profileId + } + + private fun computePreselection( + appLanguage: OppiaLanguage, + systemLanguage: OppiaLanguage + ): OppiaLanguage { + return when { + appLanguage != OppiaLanguage.LANGUAGE_UNSPECIFIED -> appLanguage + systemLanguage != OppiaLanguage.LANGUAGE_UNSPECIFIED -> systemLanguage + else -> OppiaLanguage.LANGUAGE_UNSPECIFIED + } + } + private fun createItemViewModel(language: AudioLanguage): AudioLanguageItemViewModel { return AudioLanguageItemViewModel( language, @@ -31,19 +132,6 @@ class AudioLanguageSelectionViewModel @Inject constructor( ) } - // TODO(#4938): Update the pre-selection logic. - /** The pre-selected [AudioLanguage] to be shown in the language selection dropdown. */ - val defaultLanguageSelection = getLanguageDisplayName(AudioLanguage.ENGLISH_AUDIO_LANGUAGE) - - /** The list of [AudioLanguage]s supported by the app. */ - val availableAudioLanguages: List by lazy { - AudioLanguage.values().filter { it !in IGNORED_AUDIO_LANGUAGES }.map(::getLanguageDisplayName) - } - - private fun getLanguageDisplayName(audioLanguage: AudioLanguage): String { - return appLanguageResourceHandler.computeLocalizedDisplayName(audioLanguage) - } - private companion object { private val IGNORED_AUDIO_LANGUAGES = listOf( diff --git a/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt b/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt index 52a993a52f6..60220f2e02e 100644 --- a/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt @@ -55,7 +55,8 @@ class OptionsActivity : // used to initially load the suitable fragment in the case of multipane. private var isFirstOpen = true private lateinit var selectedFragment: String - private var profileId: Int? = -1 + private lateinit var profileId: ProfileId + private var internalProfileId: Int = -1 private lateinit var readingTextSizeLauncher: ActivityResultLauncher private lateinit var audioLanguageLauncher: ActivityResultLauncher @@ -94,7 +95,8 @@ class OptionsActivity : OptionsActivityParams.getDefaultInstance() ) val isFromNavigationDrawer = args?.isFromNavigationDrawer ?: false - profileId = intent.extractCurrentUserProfileId().internalId + profileId = intent.extractCurrentUserProfileId() + internalProfileId = profileId.internalId if (savedInstanceState != null) { isFirstOpen = false } @@ -116,7 +118,7 @@ class OptionsActivity : extraOptionsTitle, isFirstOpen, selectedFragment, - profileId!! + internalProfileId ) title = resourceHandler.getStringInLocale(R.string.menu_options) @@ -153,15 +155,15 @@ class OptionsActivity : AppLanguageActivity.createAppLanguageActivityIntent( this, oppiaLanguage, - profileId!! + internalProfileId ) ) } override fun routeAudioLanguageList(audioLanguage: AudioLanguage) { - audioLanguageLauncher.launch( - AudioLanguageActivity.createAudioLanguageActivityIntent(this, audioLanguage) - ) + val intent = AudioLanguageActivity.createAudioLanguageActivityIntent(this, audioLanguage) + intent.decorateWithUserProfileId(profileId) + audioLanguageLauncher.launch(intent) } override fun routeReadingTextSize(readingTextSize: ReadingTextSize) { @@ -191,7 +193,7 @@ class OptionsActivity : optionActivityPresenter.setExtraOptionTitle( resourceHandler.getStringInLocale(R.string.audio_language) ) - optionActivityPresenter.loadAudioLanguageFragment(audioLanguage) + optionActivityPresenter.loadAudioLanguageFragment(audioLanguage, profileId) } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/org/oppia/android/app/options/OptionsActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/options/OptionsActivityPresenter.kt index ccdff3ba113..e611795f4b8 100644 --- a/app/src/main/java/org/oppia/android/app/options/OptionsActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/options/OptionsActivityPresenter.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.activity.ActivityScope import org.oppia.android.app.drawer.NavigationDrawerFragment import org.oppia.android.app.model.AudioLanguage import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ReadingTextSize import javax.inject.Inject @@ -135,8 +136,8 @@ class OptionsActivityPresenter @Inject constructor( * * @param audioLanguage the initially selected audio language */ - fun loadAudioLanguageFragment(audioLanguage: AudioLanguage) { - val audioLanguageFragment = AudioLanguageFragment.newInstance(audioLanguage) + fun loadAudioLanguageFragment(audioLanguage: AudioLanguage, profileId: ProfileId) { + val audioLanguageFragment = AudioLanguageFragment.newInstance(audioLanguage, profileId) activity.supportFragmentManager .beginTransaction() .replace(R.id.multipane_options_container, audioLanguageFragment) diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt index 60f4214458e..db1b92f4f8c 100644 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt @@ -11,12 +11,10 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer import androidx.lifecycle.Transformations import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.AudioLanguage -import org.oppia.android.app.model.CellularDataPreference import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.Spotlight import org.oppia.android.app.model.State @@ -38,7 +36,6 @@ import javax.inject.Inject const val TAG_LANGUAGE_DIALOG = "LANGUAGE_DIALOG" private const val TAG_CELLULAR_DATA_DIALOG = "CELLULAR_DATA_DIALOG" -const val AUDIO_FRAGMENT_PROFILE_ID_ARGUMENT_KEY = "AUDIO_FRAGMENT_PROFILE_ID_ARGUMENT_KEY" /** The presenter for [AudioFragment]. */ @FragmentScope @@ -75,7 +72,7 @@ class AudioFragmentPresenter @Inject constructor( cellularAudioDialogController.getCellularDataPreference().toLiveData() .observe( fragment, - Observer> { + { if (it is AsyncResult.Success) { showCellularDataDialog = !it.value.hideDialog useCellularData = it.value.useCellularData @@ -103,7 +100,7 @@ class AudioFragmentPresenter @Inject constructor( }) audioViewModel.playStatusLiveData.observe( fragment, - Observer { + { prepared = it != UiAudioPlayStatus.LOADING && it != UiAudioPlayStatus.FAILED binding.audioProgressSeekBar.isEnabled = prepared @@ -156,7 +153,7 @@ class AudioFragmentPresenter @Inject constructor( private fun subscribeToAudioLanguageLiveData() { retrieveAudioLanguageCode().observe( activity, - Observer { result -> + { result -> audioViewModel.selectedLanguageCode = result audioViewModel.loadMainContentAudio(allowAutoPlay = false, reloadingContent = false) } diff --git a/app/src/main/java/org/oppia/android/app/testing/ColorBindingAdaptersTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ColorBindingAdaptersTestActivity.kt index 9d8878c520f..d9b99d434e1 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ColorBindingAdaptersTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ColorBindingAdaptersTestActivity.kt @@ -6,7 +6,7 @@ import android.os.Bundle import org.oppia.android.R import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity -/** Test activity for ViewBindingAdapters. */ +/** Test activity for ColorBindingAdapters. */ class ColorBindingAdaptersTestActivity : InjectableAutoLocalizedAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity.kt new file mode 100644 index 00000000000..02fcec01b90 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity.kt @@ -0,0 +1,26 @@ +package org.oppia.android.app.testing + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import org.oppia.android.R +import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity + +/** Test activity for [TextInputLayoutBindingAdapters]. */ +class TextInputLayoutBindingAdaptersTestActivity : InjectableAutoLocalizedAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.text_input_layout_binding_adapters_test_activity) + + supportFragmentManager.beginTransaction().add( + R.id.background, + TextInputLayoutBindingAdaptersTestFragment() + ).commitNow() + } + + companion object { + /** Intent to open this activity. */ + fun createIntent(context: Context): Intent = + Intent(context, TextInputLayoutBindingAdaptersTestActivity::class.java) + } +} diff --git a/app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestFragment.kt b/app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestFragment.kt new file mode 100644 index 00000000000..ce0167f7b33 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestFragment.kt @@ -0,0 +1,30 @@ +package org.oppia.android.app.testing + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.databinding.TextInputLayoutBindingAdaptersTestFragmentBinding + +/** Test-only fragment for verifying behaviors of [TextInputLayoutBindingAdapters]. */ +class TextInputLayoutBindingAdaptersTestFragment : InjectableFragment() { + + private lateinit var binding: TextInputLayoutBindingAdaptersTestFragmentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = TextInputLayoutBindingAdaptersTestFragmentBinding.inflate( + inflater, + container, + false + ) + + binding.lifecycleOwner = this@TextInputLayoutBindingAdaptersTestFragment + + return binding.root + } +} diff --git a/app/src/main/res/drawable/learner_otter.xml b/app/src/main/res/drawable/learner_otter.xml index 2fba3df3a5f..69f037bdca9 100644 --- a/app/src/main/res/drawable/learner_otter.xml +++ b/app/src/main/res/drawable/learner_otter.xml @@ -2,7 +2,8 @@ android:width="180dp" android:height="180dp" android:viewportWidth="500" - android:viewportHeight="500"> + android:viewportHeight="500" + android:autoMirrored="true"> + android:viewportHeight="167" + android:autoMirrored="true"> diff --git a/app/src/main/res/drawable/parent_teacher_otter.xml b/app/src/main/res/drawable/parent_teacher_otter.xml index abeec4882c4..8671cb9dbbf 100644 --- a/app/src/main/res/drawable/parent_teacher_otter.xml +++ b/app/src/main/res/drawable/parent_teacher_otter.xml @@ -2,7 +2,8 @@ android:width="180dp" android:height="180dp" android:viewportWidth="500" - android:viewportHeight="500"> + android:viewportHeight="500" + android:autoMirrored="true"> + + + + + + android:padding="@dimen/onboarding_shared_padding_small" + app:filter="@{false}" + app:languageSelection="@{viewModel.selectedAudioLanguage}"/> diff --git a/app/src/main/res/layout-land/create_profile_fragment.xml b/app/src/main/res/layout-land/create_profile_fragment.xml index 93c01c2f32d..aa839f0e2e9 100644 --- a/app/src/main/res/layout-land/create_profile_fragment.xml +++ b/app/src/main/res/layout-land/create_profile_fragment.xml @@ -124,7 +124,7 @@ android:layout_marginStart="@dimen/phone_shared_margin_xl" android:layout_marginTop="@dimen/phone_shared_margin_small" android:layout_marginEnd="@dimen/phone_shared_margin_medium" - android:text="@string/create_profile_activity_nickname_error" + android:text="@{viewModel.errorMessage}" android:visibility="@{viewModel.hasErrorMessage ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/create_profile_nickname_edittext" /> diff --git a/app/src/main/res/layout-land/onboarding_app_language_selection_fragment.xml b/app/src/main/res/layout-land/onboarding_app_language_selection_fragment.xml index 06652937c5a..cbe45fadc7a 100644 --- a/app/src/main/res/layout-land/onboarding_app_language_selection_fragment.xml +++ b/app/src/main/res/layout-land/onboarding_app_language_selection_fragment.xml @@ -96,6 +96,7 @@ app:startIconTint="@color/component_color_shared_black_background_color"> + + + + + + android:padding="@dimen/onboarding_shared_padding_small" + app:filter="@{false}" + app:languageSelection="@{viewModel.selectedAudioLanguage}" /> diff --git a/app/src/main/res/layout-sw600dp-land/create_profile_fragment.xml b/app/src/main/res/layout-sw600dp-land/create_profile_fragment.xml index 5eba49ebb23..3e33448c69f 100644 --- a/app/src/main/res/layout-sw600dp-land/create_profile_fragment.xml +++ b/app/src/main/res/layout-sw600dp-land/create_profile_fragment.xml @@ -121,7 +121,7 @@ android:layout_height="wrap_content" android:layout_marginTop="@dimen/tablet_shared_margin_x_small" android:layout_marginEnd="@dimen/tablet_shared_margin_small" - android:text="@string/create_profile_activity_nickname_error" + android:text="@{viewModel.errorMessage}" android:visibility="@{viewModel.hasErrorMessage ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="@id/create_profile_nickname_edittext" app:layout_constraintTop_toBottomOf="@id/create_profile_nickname_edittext" /> diff --git a/app/src/main/res/layout-sw600dp-land/onboarding_app_language_selection_fragment.xml b/app/src/main/res/layout-sw600dp-land/onboarding_app_language_selection_fragment.xml index a319e663457..7b27335c708 100644 --- a/app/src/main/res/layout-sw600dp-land/onboarding_app_language_selection_fragment.xml +++ b/app/src/main/res/layout-sw600dp-land/onboarding_app_language_selection_fragment.xml @@ -105,6 +105,7 @@ app:startIconTint="@color/component_color_shared_black_background_color"> + + + + + + android:padding="@dimen/onboarding_shared_padding_small" + app:filter="@{false}" + app:languageSelection="@{viewModel.selectedAudioLanguage}" /> diff --git a/app/src/main/res/layout-sw600dp-port/create_profile_fragment.xml b/app/src/main/res/layout-sw600dp-port/create_profile_fragment.xml index 0edd5932959..689c67ae91f 100644 --- a/app/src/main/res/layout-sw600dp-port/create_profile_fragment.xml +++ b/app/src/main/res/layout-sw600dp-port/create_profile_fragment.xml @@ -120,7 +120,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="@dimen/tablet_shared_margin_small" - android:text="@string/create_profile_activity_nickname_error" + android:text="@{viewModel.errorMessage}" android:visibility="@{viewModel.hasErrorMessage ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="@id/create_profile_nickname_edittext" app:layout_constraintTop_toBottomOf="@id/create_profile_nickname_edittext" /> diff --git a/app/src/main/res/layout-sw600dp-port/onboarding_app_language_selection_fragment.xml b/app/src/main/res/layout-sw600dp-port/onboarding_app_language_selection_fragment.xml index 9425ffc352d..e2fc66f56c0 100644 --- a/app/src/main/res/layout-sw600dp-port/onboarding_app_language_selection_fragment.xml +++ b/app/src/main/res/layout-sw600dp-port/onboarding_app_language_selection_fragment.xml @@ -104,6 +104,7 @@ app:startIconTint="@color/component_color_shared_black_background_color"> + + + + + + android:padding="@dimen/onboarding_shared_padding_small" + app:filter="@{false}" + app:languageSelection="@{viewModel.selectedAudioLanguage}" /> diff --git a/app/src/main/res/layout/create_profile_fragment.xml b/app/src/main/res/layout/create_profile_fragment.xml index ab53fbfc69a..40f8c4116f3 100644 --- a/app/src/main/res/layout/create_profile_fragment.xml +++ b/app/src/main/res/layout/create_profile_fragment.xml @@ -123,7 +123,7 @@ android:layout_marginStart="@dimen/phone_shared_margin_xl" android:layout_marginTop="@dimen/phone_shared_margin_small" android:layout_marginEnd="@dimen/phone_shared_margin_medium" - android:text="@string/create_profile_activity_nickname_error" + android:text="@{viewModel.errorMessage}" android:visibility="@{viewModel.hasErrorMessage ? View.VISIBLE : View.GONE}" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/create_profile_nickname_edittext" /> diff --git a/app/src/main/res/layout/onboarding_app_language_selection_fragment.xml b/app/src/main/res/layout/onboarding_app_language_selection_fragment.xml index 2c711918350..9737d8f8a59 100644 --- a/app/src/main/res/layout/onboarding_app_language_selection_fragment.xml +++ b/app/src/main/res/layout/onboarding_app_language_selection_fragment.xml @@ -100,6 +100,7 @@ app:startIconTint="@color/component_color_shared_black_background_color"> + diff --git a/app/src/main/res/layout/text_input_layout_binding_adapters_test_fragment.xml b/app/src/main/res/layout/text_input_layout_binding_adapters_test_fragment.xml new file mode 100644 index 00000000000..1beae3f8b41 --- /dev/null +++ b/app/src/main/res/layout/text_input_layout_binding_adapters_test_fragment.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 319d70ff93a..a1b7ad23201 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -261,6 +261,8 @@ This name is already in use by another profile. Please enter a valid name for this profile. Please choose a profile name that doesn\'t include numbers or symbols. + Profile type unknown. + An error occurred while creating a profile. Your PIN should be 3 digits long. Please make sure that both PINs match. More information on 3-digit PINs. diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt index 393311789b3..b0054de19ff 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt @@ -91,7 +91,7 @@ import org.robolectric.annotation.LooperMode import javax.inject.Inject import javax.inject.Singleton -/** Tests for [MarginBindingAdapters]. */ +/** Tests for [ColorBindingAdapters]. */ @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config( diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt new file mode 100644 index 00000000000..844f2e70327 --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt @@ -0,0 +1,239 @@ +package org.oppia.android.app.databinding + +import android.app.Application +import android.content.Context +import android.widget.AutoCompleteTextView +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.textfield.TextInputLayout +import com.google.common.truth.Truth.assertThat +import dagger.Component +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentFactory +import org.oppia.android.app.activity.route.ActivityRouterModule +import org.oppia.android.app.application.ApplicationComponent +import org.oppia.android.app.application.ApplicationInjector +import org.oppia.android.app.application.ApplicationInjectorProvider +import org.oppia.android.app.application.ApplicationModule +import org.oppia.android.app.application.ApplicationStartupListenerModule +import org.oppia.android.app.application.testing.TestingBuildFlavorModule +import org.oppia.android.app.devoptions.DeveloperOptionsModule +import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule +import org.oppia.android.app.shim.ViewBindingShimModule +import org.oppia.android.app.testing.TextInputLayoutBindingAdaptersTestActivity +import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.data.backends.gae.NetworkConfigProdModule +import org.oppia.android.data.backends.gae.NetworkModule +import org.oppia.android.domain.classify.InteractionsModule +import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule +import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule +import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule +import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule +import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.ExplorationProgressModule +import org.oppia.android.domain.exploration.ExplorationStorageModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule +import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule +import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule +import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.domain.question.QuestionModule +import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.TestImageLoaderModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.espresso.EditTextInputAction +import org.oppia.android.testing.firebase.TestAuthenticationModule +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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.gcsresource.GcsResourceModule +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.EventLoggingConfigurationModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.logging.SyncStatusModule +import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule +import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule +import org.oppia.android.util.parser.image.ImageParsingModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [TextInputLayoutBindingAdapters]. */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + application = TextInputLayoutBindingAdaptersTest.TestApplication::class, + qualifiers = "port-xxhdpi" +) +class TextInputLayoutBindingAdaptersTest { + @Inject + lateinit var context: Context + + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Inject + lateinit var editTextInputAction: EditTextInputAction + + @Before + fun setUp() { + setUpTestApplicationComponent() + Intents.init() + testCoroutineDispatchers.registerIdlingResource() + } + + @After + fun tearDown() { + testCoroutineDispatchers.registerIdlingResource() + Intents.release() + } + + @Test + fun testBindingAdapters_setErrorMessage_setsMessageCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: TextInputLayout = activity.findViewById(R.id.test_text_input_view) + TextInputLayoutBindingAdapters.setErrorMessage(testView, "Some error message.") + assertThat(testView.error).isEqualTo("Some error message.") + } + } + } + + @Test + fun testBindingAdapters_setSelection_filterDisabled_setsSelectionCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: AutoCompleteTextView = activity.findViewById(R.id.test_autocomplete_view) + TextInputLayoutBindingAdapters.setLanguageSelection(testView, OppiaLanguage.ENGLISH, false) + assertThat(testView.text.toString()).isEqualTo("English") + } + } + } + + @Test + fun testBindingAdapters_setSelection_filterEnabled_setsSelectionCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: AutoCompleteTextView = activity.findViewById(R.id.test_autocomplete_view) + TextInputLayoutBindingAdapters.setLanguageSelection(testView, OppiaLanguage.ENGLISH, true) + assertThat(testView.text.toString()).isEqualTo("English") + } + } + } + + @Test + fun testBindingAdapters_setSelection_arabicLanguage_setsSelectionCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: AutoCompleteTextView = activity.findViewById(R.id.test_autocomplete_view) + TextInputLayoutBindingAdapters.setLanguageSelection(testView, OppiaLanguage.ARABIC, true) + assertThat(testView.text.toString()).isEqualTo( + context.getString(R.string.arabic_localized_language_name) + ) + } + } + } + + private fun launchActivity(): + ActivityScenario? { + val scenario = ActivityScenario.launch( + TextInputLayoutBindingAdaptersTestActivity.createIntent(context) + ) + testCoroutineDispatchers.runCurrent() + return scenario + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. + @Singleton + @Component( + modules = [ + RobolectricModule::class, + PlatformParameterModule::class, PlatformParameterSingletonModule::class, + TestDispatcherModule::class, ApplicationModule::class, + LoggerModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, TestImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, + AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class, + ExpirationMetaDataRetrieverModule::class, + ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class, + ApplicationStartupListenerModule::class, LogReportWorkerModule::class, + HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class, + FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, + DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, + ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, LocaleProdModule::class, ActivityRecreatorTestModule::class, + NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class, + MathEquationInputModule::class, SplitScreenInteractionModule::class, + LoggingIdentifierModule::class, ApplicationLifecycleModule::class, + SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class, + EventLoggingConfigurationModule::class, ActivityRouterModule::class, + CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class, + TestAuthenticationModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder : ApplicationComponent.Builder { + override fun build(): TestApplicationComponent + } + + fun inject(textInputLayoutBindingAdaptersTest: TextInputLayoutBindingAdaptersTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerTextInputLayoutBindingAdaptersTest_TestApplicationComponent.builder() + .setApplication(this) + .build() as TestApplicationComponent + } + + fun inject(textInputLayoutBindingAdaptersTest: TextInputLayoutBindingAdaptersTest) { + component.inject(textInputLayoutBindingAdaptersTest) + } + + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() + } + + override fun getApplicationInjector(): ApplicationInjector = component + } +} diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt index 5e9c8ada80e..c59489c20c9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt @@ -20,6 +20,7 @@ import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -28,13 +29,10 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.google.protobuf.MessageLite import dagger.Component -import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not -import org.hamcrest.TypeSafeMatcher import org.junit.After import org.junit.Before import org.junit.Rule @@ -52,10 +50,14 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.CreateProfileActivityParams import org.oppia.android.app.model.IntroActivityParams +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ProfileType import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.app.utility.EspressoTestsMatchers.hasProtoExtra import org.oppia.android.app.utility.OrientationChangeAction.Companion.orientationLandscape import org.oppia.android.data.backends.gae.NetworkConfigProdModule import org.oppia.android.data.backends.gae.NetworkModule @@ -87,6 +89,7 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.DisableAccessibilityChecks import org.oppia.android.testing.OppiaTestRule import org.oppia.android.testing.TestImageLoaderModule import org.oppia.android.testing.TestLogReportingModule @@ -94,6 +97,7 @@ import org.oppia.android.testing.espresso.EditTextInputAction import org.oppia.android.testing.firebase.TestAuthenticationModule import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule +import org.oppia.android.testing.profile.ProfileTestHelper import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule @@ -101,7 +105,7 @@ import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule -import org.oppia.android.util.extensions.getProtoExtra +import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EventLoggingConfigurationModule @@ -113,6 +117,8 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.ImageParsingModule import org.oppia.android.util.parser.image.TestGlideImageLoader +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -128,18 +134,27 @@ import javax.inject.Singleton qualifiers = "port-xxhdpi" ) class CreateProfileFragmentTest { - @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() - @get:Rule val oppiaTestRule = OppiaTestRule() - @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - @Inject lateinit var context: Context - @Inject lateinit var editTextInputAction: EditTextInputAction - @Inject lateinit var testGlideImageLoader: TestGlideImageLoader + @get:Rule + val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() + @get:Rule + val oppiaTestRule = OppiaTestRule() + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + lateinit var context: Context + @Inject + lateinit var editTextInputAction: EditTextInputAction + @Inject + lateinit var testGlideImageLoader: TestGlideImageLoader + @Inject + lateinit var profileTestHelper: ProfileTestHelper @Before fun setUp() { Intents.init() setUpTestApplicationComponent() testCoroutineDispatchers.registerIdlingResource() + profileTestHelper.createDefaultAdminProfile() } @After @@ -195,6 +210,7 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -203,13 +219,15 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } } @Test + @DisableAccessibilityChecks fun testFragment_continueButtonClicked_filledNickname_doesNotShowErrorText() { launchNewLearnerProfileActivity().use { onView(withId(R.id.create_profile_nickname_edittext)) @@ -218,11 +236,12 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() - onView(withText(R.string.create_profile_activity_nickname_error)) + onView(withId(R.id.create_profile_nickname_error)) .check(matches(withEffectiveVisibility(Visibility.GONE))) } } @@ -244,6 +263,7 @@ class CreateProfileFragmentTest { onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() + onView(withText(R.string.create_profile_activity_nickname_error)) .check(matches(isDisplayed())) @@ -253,6 +273,7 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -261,7 +282,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -290,12 +312,15 @@ class CreateProfileFragmentTest { @Test fun testFragment_landscapeMode_filledNickname_continueButtonClicked_launchesLearnerIntroScreen() { launchNewLearnerProfileActivity().use { + onView(isRoot()).perform(orientationLandscape()) + onView(withId(R.id.create_profile_nickname_edittext)) .perform( editTextInputAction.appendText("John"), closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -304,7 +329,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -365,7 +391,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -451,6 +478,155 @@ class CreateProfileFragmentTest { } } + @Test + fun testFragment_inputNameWithNumbers_showsNameOnlyLettersError() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + } + } + + @Test + fun testFragment_landscape_inputNameWithNumbers_showsNameOnlyLettersError() { + launchNewLearnerProfileActivity().use { + onView(isRoot()).perform(orientationLandscape()) + + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + } + } + + @Test + fun testFragment_inputNameWithNumbers_configChange_errorIsRetained() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + onView(withText(R.string.add_profile_error_name_only_letters)) + .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + } + } + + @Test + fun testFragment_inputNameWithNumbers_thenInputNameWithLetters_errorIsCleared() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withEffectiveVisibility(Visibility.GONE))) + } + } + + @Test + fun testFragment_inputNameWithNumbers_configChange_thenInputNameWithLetters_errorIsCleared() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withEffectiveVisibility(Visibility.GONE))) + } + } + + @Test + fun testFragment_profileTypeArgumentMissing_showsUnknownProfileTypeError() { + val intent = CreateProfileActivity.createProfileActivityIntent(context) + // Not adding the profile type intent parameter to trigger the exception. + intent.decorateWithUserProfileId(ProfileId.newBuilder().setInternalId(0).build()) + + val scenario = ActivityScenario.launch(intent) + testCoroutineDispatchers.runCurrent() + + scenario.use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John"), + closeSoftKeyboard() + ) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_missing_profile_type))) + } + } + private fun createGalleryPickActivityResultStub(): Instrumentation.ActivityResult { val resources: Resources = context.resources val imageUri = Uri.parse( @@ -466,27 +642,19 @@ class CreateProfileFragmentTest { private fun launchNewLearnerProfileActivity(): ActivityScenario? { - val scenario = ActivityScenario.launch( - CreateProfileActivity.createProfileActivityIntent(context) + val intent = CreateProfileActivity.createProfileActivityIntent(context) + intent.decorateWithUserProfileId(ProfileId.newBuilder().setInternalId(0).build()) + intent.putProtoExtra( + CREATE_PROFILE_PARAMS_KEY, + CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() ) + val scenario = ActivityScenario.launch(intent) testCoroutineDispatchers.runCurrent() return scenario } - private fun hasProtoExtra(keyName: String, expectedProto: T): Matcher { - val defaultProto = expectedProto.newBuilderForType().build() - return object : TypeSafeMatcher() { - override fun describeTo(description: Description) { - description.appendText("Intent with extra: $keyName and proto value: $expectedProto") - } - - override fun matchesSafely(intent: Intent): Boolean { - return intent.hasExtra(keyName) && - intent.getProtoExtra(keyName, defaultProto) == expectedProto - } - } - } - private fun setUpTestApplicationComponent() { ApplicationProvider.getApplicationContext().inject(this) } diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt index 11ded15d116..73dbd70e492 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt @@ -110,8 +110,6 @@ class IntroActivityTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - private val testProfileNickname = "John" - @Before fun setUp() { Intents.init() @@ -126,10 +124,7 @@ class IntroActivityTest { @Test fun testActivity_createIntent_verifyScreenNameInIntent() { val screenName = - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context) .extractCurrentAppScreenName() assertThat(screenName).isEqualTo(ScreenName.INTRO_ACTIVITY) @@ -151,10 +146,7 @@ class IntroActivityTest { private fun launchOnboardingLearnerIntroActivity(): ActivityScenario? { val scenario = ActivityScenario.launch( - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context) ) testCoroutineDispatchers.runCurrent() return scenario diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt index 72fea853fbc..c72f4e5721b 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt @@ -9,6 +9,9 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId @@ -33,6 +36,8 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.IntroActivityParams +import org.oppia.android.app.options.AudioLanguageActivity import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule @@ -79,6 +84,7 @@ import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EventLoggingConfigurationModule @@ -90,6 +96,7 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -185,21 +192,8 @@ class IntroFragmentTest { onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.onboarding_learner_intro_title)) - .check(matches(withText("Welcome, John!"))) - onView(withText(R.string.onboarding_learner_intro_classroom_text)) - .check(matches(isDisplayed())) - onView(withText(R.string.onboarding_learner_intro_practice_text)) - .check(matches(isDisplayed())) - onView( - withText( - context.getString( - R.string.onboarding_learner_intro_feedback_text, - context.getString(R.string.app_name) - ) - ) - ).check(matches(isDisplayed())) + intended(hasComponent(AudioLanguageActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) } } @@ -211,31 +205,21 @@ class IntroFragmentTest { onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.onboarding_learner_intro_title)) - .check(matches(withText("Welcome, John!"))) - onView(withText(R.string.onboarding_learner_intro_classroom_text)) - .check(matches(isDisplayed())) - onView(withText(R.string.onboarding_learner_intro_practice_text)) - .check(matches(isDisplayed())) - onView( - withText( - context.getString( - R.string.onboarding_learner_intro_feedback_text, - context.getString(R.string.app_name) - ) - ) - ).check(matches(isDisplayed())) + intended(hasComponent(AudioLanguageActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) } } private fun launchOnboardingLearnerIntroActivity(): ActivityScenario? { + val params = IntroActivityParams.newBuilder() + .setProfileNickname(testProfileNickname) + .build() + val scenario = ActivityScenario.launch( - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context).apply { + putProtoExtra(IntroActivity.PARAMS_KEY, params) + } ) testCoroutineDispatchers.runCurrent() return scenario diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt index 46f1b08fe88..b7c3f0f5231 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt @@ -5,8 +5,10 @@ import android.content.Context import android.content.res.Resources import android.view.View import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction @@ -19,6 +21,8 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey +import androidx.test.espresso.matcher.RootMatchers.withDecorView import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -29,10 +33,13 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.viewpager2.widget.ViewPager2 +import com.google.common.truth.Truth.assertThat import dagger.Component +import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not import org.hamcrest.Matcher +import org.hamcrest.core.IsInstanceOf.instanceOf import org.junit.After import org.junit.Rule import org.junit.Test @@ -49,6 +56,7 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.shim.ViewBindingShimModule @@ -85,9 +93,13 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.BuildEnvironment import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.TestPlatform import org.oppia.android.testing.firebase.TestAuthenticationModule +import org.oppia.android.testing.junit.DefineAppLanguageLocaleContext import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule import org.oppia.android.testing.robolectric.RobolectricModule @@ -97,7 +109,6 @@ import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule -import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EventLoggingConfigurationModule @@ -106,12 +117,13 @@ import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule -import org.oppia.android.util.parser.html.HtmlParser import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -132,19 +144,12 @@ class OnboardingFragmentTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - @Inject - lateinit var htmlParserFactory: HtmlParser.Factory - @Inject lateinit var context: Context @Inject lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler - @Inject - @field:DefaultResourceBucketName - lateinit var resourceBucketName: String - @After fun tearDown() { testCoroutineDispatchers.unregisterIdlingResource() @@ -792,6 +797,299 @@ class OnboardingFragmentTest { } } + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_englishLocale_englishIsPreselected() { + setUpTestWithOnboardingV2Enabled() + + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.ENGLISH) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.english_localized_language_name)) + ) + } + } + + @Test + fun testOnboardingFragment_onboardingV2Enabled_englishLocale_layoutIsLtr() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.ARABIC_VALUE, + appStringIetfTag = "ar", + appStringAndroidLanguageId = "ar" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_arabicLocale_arabicIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(EGYPT_ARABIC_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.ARABIC) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.arabic_localized_language_name)) + ) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.ARABIC_VALUE, + appStringIetfTag = "ar", + appStringAndroidLanguageId = "ar" + ) + @RunOn(TestPlatform.ROBOLECTRIC) + fun testOnboardingFragment_onboardingV2Enabled_arabicLocale_layoutIsRtl() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(EGYPT_ARABIC_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_RTL) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.BRAZILIAN_PORTUGUESE_VALUE, + appStringIetfTag = "pt-BR", + appStringAndroidLanguageId = "pt", + appStringAndroidRegionId = "BR" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_portugueseLocale_portugueseIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(BRAZIL_PORTUGUESE_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.BRAZILIAN_PORTUGUESE) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.portuguese_localized_language_name)) + ) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.BRAZILIAN_PORTUGUESE_VALUE, + appStringIetfTag = "pt-BR", + appStringAndroidLanguageId = "pt", + appStringAndroidRegionId = "BR" + ) + @RunOn(TestPlatform.ROBOLECTRIC) + fun testOnboardingFragment_onboardingV2Enabled_portugueseLocale_layoutIsLtr() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(BRAZIL_PORTUGUESE_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.NIGERIAN_PIDGIN_VALUE, + appStringIetfTag = "pcm", + appStringAndroidLanguageId = "pcm", + appStringAndroidRegionId = "NG" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_nigeriaLocale_naijaIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(NIGERIA_NAIJA_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.NIGERIAN_PIDGIN) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.NIGERIAN_PIDGIN_VALUE, + appStringIetfTag = "pcm", + appStringAndroidLanguageId = "pcm", + appStringAndroidRegionId = "NG" + ) + @RunOn(TestPlatform.ROBOLECTRIC) + fun testOnboardingFragment_onboardingV2Enabled_nigeriaLocale_layoutIsLtr() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(NIGERIA_NAIJA_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.LANGUAGE_UNSPECIFIED_VALUE, + appStringIetfTag = "fr", + appStringAndroidLanguageId = "fr-CA", + appStringAndroidRegionId = "CA" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_unsupportedLocale_englishIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(CANADA_FRENCH_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.LANGUAGE_UNSPECIFIED) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.english_localized_language_name)) + ) + } + } + + @Test + fun testFragment_onboardingV2Enabled_clickLetsGoButton_launchesProfileTypeScreen() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + // Verifies that the default language selection is set if the user does not make a selection. + onView(withId(R.id.onboarding_language_lets_go_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(OnboardingProfileTypeActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_onboardingV2_languageSelectionChanged_languageIsUpdated() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.onboarding_language_dropdown)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + + onView(withId(R.id.onboarding_language_lets_go_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(OnboardingProfileTypeActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + } + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_onboardingV2_languageSelectionChanged_configChange_languageIsUpdated() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.onboarding_language_dropdown)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + onView(isRoot()).perform(orientationLandscape()) + + testCoroutineDispatchers.runCurrent() + + // Verifies that the selected language is still set successfully after configuration change. + onView(withId(R.id.onboarding_language_lets_go_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(OnboardingProfileTypeActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + } + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_onboardingV2_orientationChange_languageSelectionIsRestored() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.onboarding_language_dropdown)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + } + } + } + + private fun forceDefaultLocale(locale: Locale) { + context.applicationContext.resources.configuration.setLocale(locale) + Locale.setDefault(locale) + } + private fun setUpTestWithOnboardingV2Disabled() { TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) setUp() @@ -890,4 +1188,11 @@ class OnboardingFragmentTest { override fun getApplicationInjector(): ApplicationInjector = component } + + private companion object { + private val BRAZIL_PORTUGUESE_LOCALE = Locale("pt", "BR") + private val EGYPT_ARABIC_LOCALE = Locale("ar", "EG") + private val NIGERIA_NAIJA_LOCALE = Locale("pcm", "NG") + private val CANADA_FRENCH_LOCALE = Locale("fr", "CA") + } } diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt index 2649e11c610..fbeb04c4f11 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt @@ -11,6 +11,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -37,10 +38,13 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.CreateProfileActivityParams +import org.oppia.android.app.model.ProfileType import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.app.utility.EspressoTestsMatchers.hasProtoExtra import org.oppia.android.app.utility.OrientationChangeAction.Companion.orientationLandscape import org.oppia.android.data.backends.gae.NetworkConfigProdModule import org.oppia.android.data.backends.gae.NetworkModule @@ -96,6 +100,7 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -257,12 +262,14 @@ class OnboardingProfileTypeFragmentTest { launchOnboardingProfileTypeActivity().use { onView(withId(R.id.profile_type_learner_navigation_card)).perform(click()) testCoroutineDispatchers.runCurrent() - // Does nothing for now, but should fail once navigation is implemented in a future PR. - onView(withId(R.id.profile_type_learner_navigation_card)) - .check(matches(isDisplayed())) - onView(withId(R.id.profile_type_supervisor_navigation_card)) - .check(matches(isDisplayed())) + val params = CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() + + intended(hasComponent(CreateProfileActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + intended(hasProtoExtra(CREATE_PROFILE_PARAMS_KEY, params)) } } @@ -271,9 +278,17 @@ class OnboardingProfileTypeFragmentTest { launchOnboardingProfileTypeActivity().use { onView(isRoot()).perform(orientationLandscape()) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.profile_type_learner_navigation_card)).perform(click()) testCoroutineDispatchers.runCurrent() + + val params = CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() + intended(hasComponent(CreateProfileActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + intended(hasProtoExtra(CREATE_PROFILE_PARAMS_KEY, params)) } } diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt index b8aed771aaa..8195ac0f683 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt @@ -6,10 +6,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.withDecorView import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -19,6 +23,11 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import dagger.Component +import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.CoreMatchers.not +import org.hamcrest.core.AllOf.allOf import org.junit.After import org.junit.Rule import org.junit.Test @@ -35,6 +44,7 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.home.HomeActivity import org.oppia.android.app.model.AudioLanguage import org.oppia.android.app.model.AudioLanguage.BRAZILIAN_PORTUGUESE_LANGUAGE import org.oppia.android.app.model.AudioLanguage.ENGLISH_AUDIO_LANGUAGE @@ -75,8 +85,11 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.BuildEnvironment import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.TestPlatform import org.oppia.android.testing.firebase.TestAuthenticationModule import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule @@ -311,22 +324,13 @@ class AudioLanguageFragmentTest { launch( createDefaultAudioActivityIntent(ENGLISH_AUDIO_LANGUAGE) ).use { + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.audio_language_text)).check( - matches(withText("In Oppia, you can listen to lessons!")) - ) - onView(withId(R.id.audio_language_subtitle)).check( - matches(withText(context.getString(R.string.audio_language_fragment_subtitle))) - ) - onView(withId(R.id.onboarding_navigation_back)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) - onView(withId(R.id.onboarding_navigation_continue)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) + // Verifies that accepting the default language selection works correctly. + intended(hasComponent(HomeActivity::class.java.name)) } } @@ -341,19 +345,73 @@ class AudioLanguageFragmentTest { onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.audio_language_text)).check( - matches(withText("In Oppia, you can listen to lessons!")) - ) - onView(withId(R.id.audio_language_subtitle)).check( - matches(withText(context.getString(R.string.audio_language_fragment_subtitle))) - ) - onView(withId(R.id.onboarding_navigation_back)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) - onView(withId(R.id.onboarding_navigation_continue)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) + // Verifies that accepting the default language selection works correctly. + intended(hasComponent(HomeActivity::class.java.name)) + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_languageSelectionChanged_selectionIsUpdated() { + initializeTestApplicationComponent(enableOnboardingFlowV2 = true) + launch( + createDefaultAudioActivityIntent(ENGLISH_AUDIO_LANGUAGE) + ).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.audio_language_dropdown_list)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.audio_language_dropdown_list)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(HomeActivity::class.java.name)) + } + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_languageSelectionChanged_configChange_selectionIsUpdated() { + initializeTestApplicationComponent(enableOnboardingFlowV2 = true) + launch( + createDefaultAudioActivityIntent(ENGLISH_AUDIO_LANGUAGE) + ).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.audio_language_dropdown_list)).perform(click()) + + onData( + CoreMatchers.allOf( + `is`(instanceOf(String::class.java)), `is`("Naijá") + ) + ) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + // Verifies that the selected language is still set successfully after configuration change. + onView(withId(R.id.audio_language_dropdown_list)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + intended(hasComponent(HomeActivity::class.java.name)) + } } } @@ -512,9 +570,7 @@ class AudioLanguageFragmentTest { ) interface TestApplicationComponent : ApplicationComponent { @Component.Builder - interface Builder : ApplicationComponent.Builder { - override fun build(): TestApplicationComponent - } + interface Builder : ApplicationComponent.Builder fun inject(audioLanguageFragmentTest: AudioLanguageFragmentTest) } 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 983caebf6db..95438d0b9d0 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 @@ -16,6 +16,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 @@ -78,6 +79,8 @@ private const val SET_LAST_SELECTED_CLASSROOM_ID_PROVIDER_ID = "set_last_selected_classroom_id_provider_id" private const val RETRIEVE_LAST_SELECTED_CLASSROOM_ID_PROVIDER_ID = "retrieve_last_selected_classroom_id_provider_id" +private const val UPDATE_PROFILE_DETAILS_PROVIDER_ID = "update_profile_details_data_provider_id" +private const val UPDATE_PROFILE_TYPE_PROVIDER_ID = "update_profile_type_data_provider_id" /** Controller for retrieving, adding, updating, and deleting profiles. */ @Singleton @@ -112,7 +115,7 @@ class ProfileManagementController @Inject constructor( /** Indicates that the selected image was not stored properly. */ class FailedToStoreImageException(msg: String) : Exception(msg) - /** Indicates that the profile's directory was not delete properly. */ + /** Indicates that the profile's directory was not deleted properly. */ class FailedToDeleteDirException(msg: String) : Exception(msg) /** Indicates that the given profileId is not associated with an existing profile. */ @@ -124,6 +127,9 @@ class ProfileManagementController @Inject constructor( /** Indicates that the Profile already has admin. */ class ProfileAlreadyHasAdminException(msg: String) : Exception(msg) + /** Indicates that the a ProfileType was not passed. */ + class UnknownProfileTypeException(msg: String) : Exception(msg) + /** Indicates that the there is not device settings currently. */ class DeviceSettingsNotFoundException(msg: String) : Exception(msg) @@ -169,7 +175,10 @@ class ProfileManagementController @Inject constructor( * Indicates that the operation failed due to an attempt to re-elevate an administrator to * administrator status (this should never happen in regular app operations). */ - PROFILE_ALREADY_HAS_ADMIN + PROFILE_ALREADY_HAS_ADMIN, + + /** Indicates that the operation failed due to the profileType property not supplied. */ + PROFILE_TYPE_UNKNOWN, } // TODO(#272): Remove init block when storeDataAsync is fixed @@ -365,7 +374,7 @@ class ProfileManagementController @Inject constructor( * Updates the name of an existing profile. * * @param profileId the ID corresponding to the profile being updated. - * @param newName New name for the profile being updated. + * @param newName new name for the profile being updated. * @return a [DataProvider] that indicates the success/failure of this update operation. */ fun updateName(profileId: ProfileId, newName: String): DataProvider { @@ -395,6 +404,47 @@ class ProfileManagementController @Inject constructor( } } + /** + * Updates the profile type field of an existing profile. + * + * @param profileId the ID of the profile to update + * @return a [DataProvider] that represents the result of the update operation + */ + fun updateProfileType( + profileId: ProfileId, + profileType: ProfileType + ): DataProvider { + val deferred = profileDataStore.storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { + val profile = + it.profilesMap[profileId.internalId] ?: return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.PROFILE_NOT_FOUND + ) + + val updatedProfile = profile.toBuilder() + + if (profileType == ProfileType.PROFILE_TYPE_UNSPECIFIED) { + return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.PROFILE_TYPE_UNKNOWN + ) + } else { + updatedProfile.profileType = profileType + } + + val profileDatabaseBuilder = it.toBuilder().putProfiles( + profileId.internalId, + updatedProfile.build() + ) + Pair(profileDatabaseBuilder.build(), ProfileActionStatus.SUCCESS) + } + return dataProviders.createInMemoryDataProviderAsync(UPDATE_PROFILE_TYPE_PROVIDER_ID) { + return@createInMemoryDataProviderAsync getDeferredResult(profileId, null, deferred) + } + } + /** * Updates the PIN of an existing profile. * @@ -679,6 +729,77 @@ class ProfileManagementController @Inject constructor( ).transform(UPDATE_AUDIO_LANGUAGE_PROVIDER_ID) { value -> value } } + /** + * Updates the provided details of an newly created profile to migrate onboarding flow v2 support. + * + * @param profileId the ID of the profile to update + * @param avatarImagePath the path to the profile's avatar image, or null if unset + * @param colorRgb the randomly selected unique color to be used in place of a picture + * @param newName the nickname to identify the profile + * @param isAdmin whether the profile has administrator privileges + * @return [DataProvider] that represents the result of the update operation + */ + fun updateNewProfileDetails( + profileId: ProfileId, + profileType: ProfileType, + avatarImagePath: Uri?, + colorRgb: Int, + newName: String, + isAdmin: Boolean + ): DataProvider { + val deferred = profileDataStore.storeDataWithCustomChannelAsync( + updateInMemoryCache = true + ) { + if (!enableLearnerStudyAnalytics.value && !profileNameValidator.isNameValid(newName)) { + return@storeDataWithCustomChannelAsync Pair(it, ProfileActionStatus.INVALID_PROFILE_NAME) + } + val profile = + it.profilesMap[profileId.internalId] ?: return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.PROFILE_NOT_FOUND + ) + val profileDir = directoryManagementUtil.getOrCreateDir(profileId.toString()) + + val updatedProfile = profile.toBuilder() + + if (avatarImagePath != null) { + val imageUri = + saveImageToInternalStorage(avatarImagePath, profileDir) + ?: return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.FAILED_TO_STORE_IMAGE + ) + updatedProfile.avatar = + ProfileAvatar.newBuilder().setAvatarImageUri(imageUri).build() + } else { + updatedProfile.avatar = + ProfileAvatar.newBuilder().setAvatarColorRgb(colorRgb).build() + } + + if (profileType == ProfileType.PROFILE_TYPE_UNSPECIFIED) { + return@storeDataWithCustomChannelAsync Pair( + it, + ProfileActionStatus.PROFILE_TYPE_UNKNOWN + ) + } else { + updatedProfile.profileType = profileType + } + + updatedProfile.name = newName + + updatedProfile.isAdmin = isAdmin + + val profileDatabaseBuilder = it.toBuilder().putProfiles( + profileId.internalId, + updatedProfile.build() + ) + Pair(profileDatabaseBuilder.build(), ProfileActionStatus.SUCCESS) + } + return dataProviders.createInMemoryDataProviderAsync(UPDATE_PROFILE_DETAILS_PROVIDER_ID) { + return@createInMemoryDataProviderAsync getDeferredResult(profileId, newName, deferred) + } + } + /** * Log in to the user's Profile by setting the current profile Id, updating profile's last logged * in time and updating the total number of logins for the current profile Id. @@ -962,6 +1083,8 @@ class ProfileManagementController @Inject constructor( "Profile cannot be an admin" ) ) + ProfileActionStatus.PROFILE_TYPE_UNKNOWN -> + AsyncResult.Failure(UnknownProfileTypeException("ProfileType must be set.")) } } diff --git a/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt index 91d58907646..287239d6e72 100644 --- a/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/profile/ProfileManagementControllerTest.kt @@ -28,6 +28,7 @@ import org.oppia.android.app.model.AudioLanguage.NIGERIAN_PIDGIN_LANGUAGE import org.oppia.android.app.model.Profile 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.MEDIUM_TEXT_SIZE import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_1 import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_2 @@ -141,7 +142,7 @@ class ProfileManagementControllerTest { assertThat(profile.isContinueButtonAnimationSeen).isEqualTo(false) assertThat(File(getAbsoluteDirPath("0")).isDirectory).isTrue() assertThat(profile.surveyLastShownTimestampMs).isEqualTo(0L) - assertThat(profile.lastSelectedClassroomId).isEqualTo("") + assertThat(profile.lastSelectedClassroomId).isEmpty() } @Test @@ -1434,6 +1435,190 @@ class ProfileManagementControllerTest { assertThat(lastSelectedClassroomId).isEmpty() } + @Test + fun testUpdateProfile_updateMultipleFields_checkUpdateIsSuccessful() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateNewProfileDetails( + PROFILE_ID_0, + ProfileType.SOLE_LEARNER, + null, + -1, + "John", + isAdmin = true + ) + monitorFactory.waitForNextSuccessfulResult(updateProvider) + + val profileProvider = profileManagementController.getProfile(PROFILE_ID_0) + val profile = monitorFactory.waitForNextSuccessfulResult(profileProvider) + + assertThat(profile.name).isEqualTo("John") + assertThat(profile.profileType).isEqualTo(ProfileType.SOLE_LEARNER) + assertThat(profile.isAdmin).isEqualTo(true) + assertThat(profile.avatar.avatarImageUri).isEmpty() + assertThat(profile.avatar.avatarColorRgb).isEqualTo(-1) + } + + @Test + fun testUpdateProfile_updateMultipleFields_invalidName_checkNameUpdateFailed() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateNewProfileDetails( + PROFILE_ID_0, + ProfileType.SOLE_LEARNER, + null, + -1, + "John123", + isAdmin = true + ) + val failure = monitorFactory.waitForNextFailureResult(updateProvider) + + assertThat(failure).hasMessageThat().contains("John123 does not contain only letters") + } + + @Test + fun testUpdateProfile_updateMultipleFields_nullAvatarUri_setsAvatarColorSuccessfully() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateNewProfileDetails( + PROFILE_ID_0, + ProfileType.SOLE_LEARNER, + null, + -11235672, + "John", + isAdmin = true + ) + monitorFactory.waitForNextSuccessfulResult(updateProvider) + + val profileProvider = profileManagementController.getProfile(PROFILE_ID_0) + val profile = monitorFactory.waitForNextSuccessfulResult(profileProvider) + + assertThat(profile.avatar.avatarImageUri).isEmpty() + assertThat(profile.avatar.avatarColorRgb).isEqualTo(-11235672) + assertThat(profile.name).isEqualTo("John") + assertThat(profile.profileType).isEqualTo(ProfileType.SOLE_LEARNER) + assertThat(profile.isAdmin).isEqualTo(true) + } + + @Test + fun testUpdateProfile_updateMultipleFields_unspecifiedProfileType_returnsProfileTypeError() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateNewProfileDetails( + PROFILE_ID_0, + ProfileType.PROFILE_TYPE_UNSPECIFIED, + null, + -11235672, + "John", + isAdmin = true + ) + + val failure = monitorFactory.waitForNextFailureResult(updateProvider) + assertThat(failure).hasMessageThat().isEqualTo("ProfileType must be set.") + } + + @Test + fun testUpdateProfile_updateMultipleFields_invalidProfileId_checkUpdateFailed() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateNewProfileDetails( + PROFILE_ID_3, + ProfileType.SOLE_LEARNER, + null, + -1, + "John", + isAdmin = true + ) + val failure = monitorFactory.waitForNextFailureResult(updateProvider) + + assertThat(failure).hasMessageThat() + .contains("ProfileId ${PROFILE_ID_3?.internalId} does not match an existing Profile") + } + + @Test + fun testUpdateExistingAdminProfile_updateProfileTypeToSupervisor_checkProfileTypeSupervisor() { + setUpTestApplicationComponent() + profileTestHelper.addOnlyAdminProfile() + + val updateProvider = profileManagementController.updateProfileType( + PROFILE_ID_0, + ProfileType.SUPERVISOR + ) + monitorFactory.waitForNextSuccessfulResult(updateProvider) + + val updatedProfileProvider = profileManagementController.getProfile(PROFILE_ID_0) + val updatedProfile = monitorFactory.waitForNextSuccessfulResult(updatedProfileProvider) + assertThat(updatedProfile.profileType).isEqualTo(ProfileType.SUPERVISOR) + } + + @Test + fun testUpdateExistingPinlessAdmin_updateProfileTypeToSoleLearner_checkProfileTypeSoleLearner() { + setUpTestApplicationComponent() + addAdminProfile(name = "Admin", pin = "") + + val updateProvider = profileManagementController.updateProfileType( + PROFILE_ID_0, + ProfileType.SOLE_LEARNER + ) + monitorFactory.waitForNextSuccessfulResult(updateProvider) + + val updatedProfileProvider = profileManagementController.getProfile(PROFILE_ID_0) + val updatedProfile = monitorFactory.waitForNextSuccessfulResult(updatedProfileProvider) + assertThat(updatedProfile.profileType).isEqualTo(ProfileType.SOLE_LEARNER) + } + + @Test + fun testUpdateExistingNonAdminProfile_updateProfileTypeToLearner_checkProfileTypeAddLearner() { + setUpTestApplicationComponent() + addAdminProfile("Admin") + addNonAdminProfileAndWait(name = "Rajat", pin = "01234") + + val updateProvider = profileManagementController.updateProfileType( + PROFILE_ID_1, + ProfileType.ADDITIONAL_LEARNER + ) + monitorFactory.waitForNextSuccessfulResult(updateProvider) + + val updatedProfileProvider = profileManagementController.getProfile(PROFILE_ID_1) + val updatedProfile = monitorFactory.waitForNextSuccessfulResult(updatedProfileProvider) + assertThat(updatedProfile.profileType).isEqualTo(ProfileType.ADDITIONAL_LEARNER) + } + + @Test + fun testUpdateDefaultProfile_profileTypeToSoleLearner_checkProfileTypeSoleLearner() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateProfileType( + PROFILE_ID_0, + ProfileType.SOLE_LEARNER + ) + monitorFactory.waitForNextSuccessfulResult(updateProvider) + + val updatedProfileProvider = profileManagementController.getProfile(PROFILE_ID_0) + val updatedProfile = monitorFactory.waitForNextSuccessfulResult(updatedProfileProvider) + assertThat(updatedProfile.profileType).isEqualTo(ProfileType.SOLE_LEARNER) + } + + @Test + fun testUpdateDefaultProfile_profileTypeUnspecified_returnsProfileTypeError() { + setUpTestApplicationComponent() + profileTestHelper.createDefaultAdminProfile() + + val updateProvider = profileManagementController.updateProfileType( + PROFILE_ID_0, + ProfileType.PROFILE_TYPE_UNSPECIFIED + ) + + val failure = monitorFactory.waitForNextFailureResult(updateProvider) + assertThat(failure).hasMessageThat().isEqualTo("ProfileType must be set.") + } + private fun addTestProfiles() { val profileAdditionProviders = PROFILES_LIST.map { addNonAdminProfile(it.name, pin = it.pin, allowDownloadAccess = it.allowDownloadAccess) diff --git a/model/src/main/proto/arguments.proto b/model/src/main/proto/arguments.proto index 5b9e2c24af6..ac21f121a5d 100644 --- a/model/src/main/proto/arguments.proto +++ b/model/src/main/proto/arguments.proto @@ -281,6 +281,9 @@ message AudioLanguageFragmentArguments { message AudioLanguageFragmentStateBundle { // The default audio language selected by the user. AudioLanguage audio_language = 1; + + // The selected language display name. + OppiaLanguage selected_language = 2; } // Activity Parameters needed to create the policy page. @@ -886,3 +889,27 @@ message IntroActivityParams { // The nickname associated with a newly created profile. string profile_nickname = 1; } + +// Arguments required when creating a new IntroFragment. +message IntroFragmentArguments { + // The nickname associated with a newly created profile. + string profile_nickname = 1; +} + +// Params required when creating a new CreateProfileActivity. +message CreateProfileActivityParams { + // The ProfileType of the new profile as implied by the user's selection. + ProfileType profile_type = 1; +} + +// Arguments required when creating a new CreateProfileFragment. +message CreateProfileFragmentArguments { + // The ProfileType of the new profile as implied by the user's selection. + ProfileType profile_type = 1; +} + +// The bundle of properties that are saved on configuration change in OnboardingFragment. +message OnboardingFragmentStateBundle { + // The current selected language. + OppiaLanguage selected_language = 1; +} diff --git a/model/src/main/proto/profile.proto b/model/src/main/proto/profile.proto index bffdb1ec194..bb55c8b2b47 100644 --- a/model/src/main/proto/profile.proto +++ b/model/src/main/proto/profile.proto @@ -90,6 +90,24 @@ message Profile { // Represents the ID of the classroom that the user selected during their last login. string last_selected_classroom_id = 19; + + // Represents the type of user which informs the configuration options available to them. + ProfileType profile_type = 20; +} + +// Represents the type of user using the app. +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; } // Represents a profile avatar image. diff --git a/scripts/assets/accessibility_label_exemptions.textproto b/scripts/assets/accessibility_label_exemptions.textproto index a1993f3b4dd..206b77a0466 100644 --- a/scripts/assets/accessibility_label_exemptions.textproto +++ b/scripts/assets/accessibility_label_exemptions.textproto @@ -36,6 +36,7 @@ exempted_activity: "app/src/main/java/org/oppia/android/app/testing/SplashTestAc exempted_activity: "app/src/main/java/org/oppia/android/app/testing/StateAssemblerMarginBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/StateAssemblerPaddingBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivity" +exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TextViewBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TopicRevisionTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TopicTestActivity" diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index d4e3597a664..fe0d7d9aba9 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -276,6 +276,7 @@ file_content_checks { exempted_file_name: "app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt" + exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/parser/ListItemLeadingMarginSpanTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/player/audio/AudioFragmentTest.kt" diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index bbc8a2fd872..9bf4d08e951 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -1342,6 +1342,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/onboarding/OnboardingAppLanguageViewModel.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewPagerViewModel.kt" test_file_not_required: true @@ -2378,6 +2382,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/BindableAdapterTestActivity.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt" test_file_not_required: true @@ -2410,6 +2418,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/ColorBindingAdaptersTestFragment.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestFragment.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivity.kt" test_file_not_required: true diff --git a/testing/src/main/java/org/oppia/android/testing/espresso/EditTextInputAction.kt b/testing/src/main/java/org/oppia/android/testing/espresso/EditTextInputAction.kt index 0f038c038e3..b65dd4f976b 100644 --- a/testing/src/main/java/org/oppia/android/testing/espresso/EditTextInputAction.kt +++ b/testing/src/main/java/org/oppia/android/testing/espresso/EditTextInputAction.kt @@ -50,7 +50,7 @@ class EditTextInputAction @Inject constructor( override fun perform(uiController: UiController?, view: View?) { // Appending text only works on Robolectric, whereas Espresso needs to use typeText(). if (Build.FINGERPRINT.contains("robolectric", ignoreCase = true)) { - (view as? EditText)?.append(text) + (view as? EditText)?.setText(text) testCoroutineDispatchers.runCurrent() } else baseAction.perform(uiController, view) } diff --git a/testing/src/main/java/org/oppia/android/testing/profile/ProfileTestHelper.kt b/testing/src/main/java/org/oppia/android/testing/profile/ProfileTestHelper.kt index 3dc71a049a1..a5e877fa705 100644 --- a/testing/src/main/java/org/oppia/android/testing/profile/ProfileTestHelper.kt +++ b/testing/src/main/java/org/oppia/android/testing/profile/ProfileTestHelper.kt @@ -76,6 +76,16 @@ class ProfileTestHelper @Inject constructor( } } + /** Creates one admin profile with default values for all fields. */ + fun createDefaultAdminProfile() { + addProfileAndWait( + name = "", + pin = "", + allowDownloadAccess = false, + isAdmin = true + ) + } + /** Log in to admin profile. */ fun logIntoAdmin() = logIntoProfile(internalProfileId = 0) diff --git a/testing/src/test/java/org/oppia/android/testing/profile/ProfileTestHelperTest.kt b/testing/src/test/java/org/oppia/android/testing/profile/ProfileTestHelperTest.kt index dcddadc11ab..74c9ab3846c 100644 --- a/testing/src/test/java/org/oppia/android/testing/profile/ProfileTestHelperTest.kt +++ b/testing/src/test/java/org/oppia/android/testing/profile/ProfileTestHelperTest.kt @@ -102,6 +102,18 @@ class ProfileTestHelperTest { assertThat(profiles).hasSize(10) } + @Test + fun testAddDefaultProfile_createDefaultProfile_checkProfileIsAdded() { + profileTestHelper.createDefaultAdminProfile() + testCoroutineDispatchers.runCurrent() + val profilesProvider = profileManagementController.getProfiles() + testCoroutineDispatchers.runCurrent() + + val profiles = monitorFactory.waitForNextSuccessfulResult(profilesProvider) + assertThat(profiles).hasSize(1) + assertThat(profiles.first().isAdmin).isTrue() + } + @Test fun testLogIntoAdmin_initializeProfiles_logIntoAdmin_checkIsSuccessful() { profileTestHelper.initializeProfiles()