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()