diff --git a/.github/workflows/developer_onboarding_notification.yml b/.github/workflows/developer_onboarding_notification.yml new file mode 100644 index 00000000000..7d8f94cafd6 --- /dev/null +++ b/.github/workflows/developer_onboarding_notification.yml @@ -0,0 +1,73 @@ +name: Celebrating Initial Contributions + +on: + pull_request_target: + types: [closed] + +permissions: + pull-requests: write + +jobs: + comment_on_merged_pull_request: + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Environment Variables + env: + AUTHOR: ${{ github.event.pull_request.user.login }} + REPO: ${{ github.event.repository.name }} + OWNER: ${{ github.event.repository.owner.login }} + run: | + echo "AUTHOR=${AUTHOR}" >> $GITHUB_ENV + echo "REPO=${REPO}" >> $GITHUB_ENV + echo "OWNER=${OWNER}" >> $GITHUB_ENV + + - name: Count Merged Pull Requests + id: count_merged_pull_requests + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const author = process.env.AUTHOR; + const repo = process.env.REPO; + const owner = process.env.OWNER; + const { data } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} type:pr state:closed author:${author}` + }); + const prCount = data.items.filter(pr => pr.pull_request.merged_at).length; + core.exportVariable('PR_COUNT', prCount); + + - name: Comment on the Merged Pull Request + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prCount = parseInt(process.env.PR_COUNT); + const author = process.env.AUTHOR; + const mention = 'adhiamboperes'; + const prNumber = context.payload.pull_request.number; + + let message; + if (prCount === 1) { + message = `✨ **Fantastic work @${author}!** Your very first PR to Oppia has been merged! 🎉🥳\n\n` + + `You've just taken your first step into open-source, and we couldn’t be happier to have you onboard. 🙌\n` + + `If you're feeling adventurous, why not dive into another issue and keep contributing? The community would love to see more from you! 🚀\n\n` + + `For any support, feel free to reach out to the developer onboarding lead: @${mention}. Happy coding! 👩‍💻👨‍💻`; + } else if (prCount === 2) { + message = `👏 **Well done @${author}!** Two PRs merged already! 🎉🥳\n\n` + + `With your second PR, you're on a roll, and your contributions are already making a difference. 🌟\n` + + `This means you may be eligible to join the Oppia dev team as a collaborator! 🎉 If you're interested, please fill out [this form](https://forms.gle/NxPjimCMqsSTNUgu5) and become an even more integral part of the community. 🌱\n\n` + + `Looking forward to seeing even more contributions from you. The developer onboarding lead: @${mention} is here if you need any help! Keep up the great work! 🚀`; + } + + if (prCount === 1 || prCount === 2) { + await github.rest.issues.createComment({ + owner: process.env.OWNER, + repo: process.env.REPO, + issue_number: prNumber, + body: message + }); + } diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml index f37031f61e9..57f63fe6fb7 100644 --- a/.github/workflows/wiki.yml +++ b/.github/workflows/wiki.yml @@ -1,20 +1,45 @@ name: Deploy to Wiki on: + pull_request: + paths: + - 'wiki/**' push: branches: - develop paths: - 'wiki/**' - # Triggers this workflow when the wiki is changed + # Triggers this workflow when the wiki is changed. # (see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#gollum). gollum: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: + table_of_contents_check: + # To verify that the wiki's table of contents matches the headers accurately. + name: Check Wiki Table of Contents + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + + - name: Set up Bazel + uses: abhinavsingh/setup-bazel@v3 + with: + version: 6.5.0 + + - name: Check Wiki Table of Contents + id: checkWikiToc + run: | + bazel run //scripts:wiki_table_of_contents_check -- ${GITHUB_WORKSPACE} + wiki-deploy: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-20.04] + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: - uses: actions/checkout@v3 with: 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" /> + > { - override fun onChanged(startUpStateResult: AsyncResult?) { - when (startUpStateResult) { - null, is AsyncResult.Pending -> { - // Do nothing. - } - is AsyncResult.Success -> { - liveData.removeObserver(this) - - if (startUpStateResult.value.startupMode == - AppStartupState.StartupMode.USER_NOT_YET_ONBOARDED - ) { - analyticsController.logAppOnboardedEvent(profileId) - } - } - is AsyncResult.Failure -> { - oppiaLogger.e( - "ClassroomListFragment", - "Failed to retrieve app startup state" - ) - } - } - } - } - ) - } - private fun logHomeActivityEvent() { analyticsController.logImportantEvent( oppiaLogger.createOpenHomeContext(), diff --git a/app/src/main/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdapters.java b/app/src/main/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdapters.java index d0dd35c2a77..dfac960ef8f 100644 --- a/app/src/main/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdapters.java +++ b/app/src/main/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdapters.java @@ -1,8 +1,17 @@ package org.oppia.android.app.databinding; +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.view.View; +import android.widget.AutoCompleteTextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.databinding.BindingAdapter; import com.google.android.material.textfield.TextInputLayout; +import org.oppia.android.app.model.OppiaLanguage; +import org.oppia.android.app.translation.AppLanguageActivityInjectorProvider; +import org.oppia.android.app.translation.AppLanguageResourceHandler; /** Holds all custom binding adapters that bind to [TextInputLayout]. */ public final class TextInputLayoutBindingAdapters { @@ -15,4 +24,37 @@ public static void setErrorMessage( ) { textInputLayout.setError(errorMessage); } + + /** Binding adapter for setting the text of an [AutoCompleteTextView]. */ + @BindingAdapter({"languageSelection", "filter"}) + public static void setLanguageSelection( + @NonNull AutoCompleteTextView textView, + @Nullable OppiaLanguage selectedItem, + Boolean filter) { + textView.setText(getAppLanguageResourceHandler(textView) + .computeLocalizedDisplayName(selectedItem), filter); + } + + private static AppLanguageResourceHandler getAppLanguageResourceHandler(View view) { + AppLanguageActivityInjectorProvider provider = + (AppLanguageActivityInjectorProvider) getAttachedActivity(view); + return provider.getAppLanguageActivityInjector().getAppLanguageResourceHandler(); + } + + private static Activity getAttachedActivity(View view) { + Context context = view.getContext(); + while (context != null && !(context instanceof Activity)) { + if (!(context instanceof ContextWrapper)) { + throw new IllegalStateException( + "Encountered context in view (" + view + ") that doesn't wrap a parent context: " + + context + ); + } + context = ((ContextWrapper) context).getBaseContext(); + } + if (context == null) { + throw new IllegalStateException("Failed to find base Activity for view: " + view); + } + return (Activity) context; + } } diff --git a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt index b3ef5d04e3f..17d41e62f1f 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt @@ -5,7 +5,6 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.recyclerview.widget.GridLayoutManager import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope @@ -13,7 +12,6 @@ import org.oppia.android.app.home.promotedlist.ComingSoonTopicListViewModel import org.oppia.android.app.home.promotedlist.PromotedStoryListViewModel import org.oppia.android.app.home.topiclist.AllTopicsViewModel import org.oppia.android.app.home.topiclist.TopicSummaryViewModel -import org.oppia.android.app.model.AppStartupState import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.TopicSummary import org.oppia.android.app.recyclerview.BindableAdapter @@ -25,14 +23,11 @@ import org.oppia.android.databinding.HomeFragmentBinding import org.oppia.android.databinding.PromotedStoryListBinding import org.oppia.android.databinding.TopicSummaryViewBinding import org.oppia.android.databinding.WelcomeBinding -import org.oppia.android.domain.onboarding.AppStartupStateController import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.topic.TopicListController import org.oppia.android.domain.translation.TranslationController -import org.oppia.android.util.data.AsyncResult -import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import org.oppia.android.util.parser.html.TopicHtmlParserEntityType import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId @@ -53,7 +48,6 @@ class HomeFragmentPresenter @Inject constructor( private val dateTimeUtil: DateTimeUtil, private val translationController: TranslationController, private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, - private val appStartupStateController: AppStartupStateController ) { private val routeToTopicPlayStoryListener = activity as RouteToTopicPlayStoryListener private lateinit var binding: HomeFragmentBinding @@ -103,45 +97,9 @@ class HomeFragmentPresenter @Inject constructor( it.viewModel = homeViewModel } - logAppOnboardedEvent() - return binding.root } - private fun logAppOnboardedEvent() { - val startupStateProvider = appStartupStateController.getAppStartupState() - val liveData = startupStateProvider.toLiveData() - liveData.observe( - activity, - object : Observer> { - override fun onChanged(startUpStateResult: AsyncResult?) { - when (startUpStateResult) { - null, is AsyncResult.Pending -> { - // Do nothing - } - is AsyncResult.Success -> { - liveData.removeObserver(this) - - if (startUpStateResult.value.startupMode == - AppStartupState.StartupMode.USER_NOT_YET_ONBOARDED - ) { - analyticsController.logAppOnboardedEvent( - ProfileId.newBuilder().setInternalId(internalProfileId).build() - ) - } - } - is AsyncResult.Failure -> { - oppiaLogger.e( - "HomeFragment", - "Failed to retrieve app startup state" - ) - } - } - } - } - ) - } - private fun createRecyclerViewAdapter(): BindableAdapter { return multiTypeBuilderFactory.create { viewModel -> when (viewModel) { diff --git a/app/src/main/java/org/oppia/android/app/onboarding/AudioLanguageFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/AudioLanguageFragmentPresenter.kt index 3a238d4b010..43ac0698801 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/AudioLanguageFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/AudioLanguageFragmentPresenter.kt @@ -1,17 +1,31 @@ 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 com.google.android.material.appbar.AppBarLayout import org.oppia.android.R +import org.oppia.android.app.home.HomeActivity +import org.oppia.android.app.model.AudioLanguageFragmentStateBundle +import org.oppia.android.app.model.AudioTranslationLanguageSelection +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.options.AudioLanguageFragment.Companion.FRAGMENT_SAVED_STATE_KEY import org.oppia.android.app.options.AudioLanguageSelectionViewModel import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.AudioLanguageSelectionFragmentBinding +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.DataProviders.Companion.toLiveData +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.extensions.putProto import javax.inject.Inject /** The presenter for [AudioLanguageFragment]. */ @@ -19,9 +33,13 @@ class AudioLanguageFragmentPresenter @Inject constructor( private val fragment: Fragment, private val activity: AppCompatActivity, private val appLanguageResourceHandler: AppLanguageResourceHandler, - private val audioLanguageSelectionViewModel: AudioLanguageSelectionViewModel + private val audioLanguageSelectionViewModel: AudioLanguageSelectionViewModel, + private val translationController: TranslationController, + private val oppiaLogger: OppiaLogger ) { private lateinit var binding: AudioLanguageSelectionFragmentBinding + private lateinit var selectedLanguage: OppiaLanguage + private lateinit var supportedLanguages: List /** * 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/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt index 7606c7ea5ad..1dc30c6fd9e 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.topic.revisioncard import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.OnBackPressedCallback import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity import org.oppia.android.app.model.ProfileId @@ -56,6 +57,15 @@ class RevisionCardActivity : subtopicListSize ) } + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(/* enabled = */ true) { + override fun handleOnBackPressed() { + revisionCardActivityPresenter.setReadingTextSizeMedium() + onReturnToTopicRequested() + } + } + ) } override fun handleOnOptionsItemSelected(itemId: Int) { @@ -115,13 +125,6 @@ class RevisionCardActivity : revisionCardActivityPresenter.dismissConceptCard() } - // TODO(#5404): Migrate to a back pressed dispatcher. - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - revisionCardActivityPresenter.setReadingTextSizeMedium() - onReturnToTopicRequested() - } - override fun onDefaultFontSizeLoaded(readingTextSize: ReadingTextSize) { revisionCardActivityPresenter.loadRevisionCardFragment(readingTextSize) } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt index af41963e498..c77afb97738 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt @@ -83,8 +83,7 @@ class RevisionCardActivityPresenter @Inject constructor( binding.revisionCardToolbar.setNavigationOnClickListener { (activity as ReturnToTopicClickListener).onReturnToTopicRequested() fontScaleConfigurationUtil.adjustFontScale(activity, ReadingTextSize.MEDIUM_TEXT_SIZE) - @Suppress("DEPRECATION") // TODO(#5404): Migrate to a back pressed dispatcher. - activity.onBackPressed() + activity.onBackPressedDispatcher.onBackPressed() } if (!accessibilityService.isScreenReaderEnabled()) { binding.revisionCardToolbarTitle.setOnClickListener { 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 8228345af00..da7268e6ca5 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 @@ -90,7 +90,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 0f886f3d215..f45cebfb42f 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.LoggerModule @@ -112,6 +116,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 @@ -127,18 +133,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 @@ -194,6 +209,7 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -202,13 +218,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)) @@ -217,11 +235,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))) } } @@ -243,6 +262,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())) @@ -252,6 +272,7 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -260,7 +281,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -289,12 +311,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() @@ -303,7 +328,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -364,7 +390,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -450,6 +477,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( @@ -465,27 +641,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 b880e500caa..c0f92b58bca 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 @@ -109,8 +109,6 @@ class IntroActivityTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - private val testProfileNickname = "John" - @Before fun setUp() { Intents.init() @@ -125,10 +123,7 @@ class IntroActivityTest { @Test fun testActivity_createIntent_verifyScreenNameInIntent() { val screenName = - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context) .extractCurrentAppScreenName() assertThat(screenName).isEqualTo(ScreenName.INTRO_ACTIVITY) @@ -150,10 +145,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 295e0bc93da..0db62fa800b 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.LoggerModule @@ -89,6 +95,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 @@ -184,21 +191,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)) } } @@ -210,31 +204,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 2e59edea781..c985ce11ab4 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.LoggerModule @@ -105,12 +116,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 @@ -131,19 +143,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() @@ -791,6 +796,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() @@ -889,4 +1187,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 17e31946afa..8493d3ae7ed 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 @@ -95,6 +99,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 @@ -256,12 +261,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)) } } @@ -270,9 +277,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 b292b6322e4..cb3fef2835f 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 @@ -310,22 +323,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)) } } @@ -340,19 +344,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)) + } } } @@ -511,9 +569,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/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt index c1dd20cd006..9930513107a 100644 --- a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt @@ -27,7 +27,6 @@ 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.EventLog -import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.COMPLETE_APP_ONBOARDING import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_HOME import org.oppia.android.app.model.ProfileId import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule @@ -140,18 +139,6 @@ class HomeActivityLocalTest { } } - @Test - fun testHomeActivity_onFirstLaunch_logsCompletedOnboardingEvent() { - setUpTestApplicationComponent() - launch(createHomeActivityIntent(profileId)).use { - testCoroutineDispatchers.runCurrent() - val event = fakeAnalyticsEventLogger.getMostRecentEvent() - - assertThat(event.priority).isEqualTo(EventLog.Priority.OPTIONAL) - assertThat(event.context.activityContextCase).isEqualTo(COMPLETE_APP_ONBOARDING) - } - } - @Test fun testHomeActivity_onSubsequentLaunch_doesNotLogCompletedOnboardingEvent() { executeInPreviousAppInstance { testComponent -> diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt index ee30f69b061..43e959982c6 100644 --- a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt +++ b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt @@ -6,8 +6,10 @@ import org.oppia.android.app.model.AppStartupState.StartupMode import org.oppia.android.app.model.BuildFlavor import org.oppia.android.app.model.DeprecationResponseDatabase import org.oppia.android.app.model.OnboardingState +import org.oppia.android.app.model.ProfileId import org.oppia.android.data.persistence.PersistentCacheStore import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders.Companion.combineWith import org.oppia.android.util.extensions.getStringFromBundle @@ -31,6 +33,7 @@ class AppStartupStateController @Inject constructor( private val deprecationController: DeprecationController, @EnableAppAndOsDeprecation private val enableAppAndOsDeprecation: Provider>, + private val analyticsController: AnalyticsController, ) { private val onboardingFlowStore by lazy { cacheStoreFactory.create("on_boarding_flow", OnboardingState.getDefaultInstance()) @@ -65,8 +68,9 @@ class AppStartupStateController @Inject constructor( * Note that this does not notify existing subscribers of the changed state, nor can future * subscribers observe this state until the app restarts. */ - fun markOnboardingFlowCompleted() { + fun markOnboardingFlowCompleted(profileId: ProfileId? = null) { updateOnboardingState { alreadyOnboardedApp = true } + logAppOnboardedEvent(profileId) } /** @@ -190,4 +194,8 @@ class AppStartupStateController @Inject constructor( expirationDate?.isBeforeToday() ?: true } else false } + + private fun logAppOnboardedEvent(profileId: ProfileId?) { + analyticsController.logAppOnboardedEvent(profileId) + } } diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel index 0c56bb8f283..34dc94addea 100644 --- a/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/onboarding/BUILD.bazel @@ -15,6 +15,7 @@ kt_android_library( ":exploration_meta_data_retriever", "//data/src/main/java/org/oppia/android/data/persistence:cache_store", "//domain/src/main/java/org/oppia/android/domain/oppialogger:oppia_logger", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:controller", "//model/src/main/proto:deprecation_java_proto_lite", "//model/src/main/proto:onboarding_java_proto_lite", "//third_party:javax_inject_javax_inject", diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel index 3a91ed280e8..f27fbd4b280 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel @@ -20,7 +20,7 @@ kt_android_library( srcs = [ "AnalyticsController.kt", ], - visibility = ["//domain/src/main/java/org/oppia/android/domain/oppialogger:__subpackages__"], + visibility = ["//:oppia_api_visibility"], deps = [ "//:dagger", "//data/src/main/java/org/oppia/android/data/backends/gae:network_interceptors", 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/onboarding/AppStartupStateControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/onboarding/AppStartupStateControllerTest.kt index 92c96177489..64aafa421d5 100644 --- a/domain/src/test/java/org/oppia/android/domain/onboarding/AppStartupStateControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/onboarding/AppStartupStateControllerTest.kt @@ -26,6 +26,7 @@ import org.oppia.android.app.model.AppStartupState.StartupMode.USER_NOT_YET_ONBO import org.oppia.android.app.model.BuildFlavor import org.oppia.android.app.model.DeprecationNoticeType import org.oppia.android.app.model.DeprecationResponse +import org.oppia.android.app.model.EventLog import org.oppia.android.app.model.OnboardingState import org.oppia.android.app.model.PlatformParameter import org.oppia.android.data.persistence.PersistentCacheStore @@ -41,6 +42,7 @@ import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule import org.oppia.android.domain.platformparameter.PlatformParameterController import org.oppia.android.domain.platformparameter.PlatformParameterModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.testing.FakeAnalyticsEventLogger import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.data.DataProviderTestMonitor import org.oppia.android.testing.junit.OppiaParameterizedTestRunner @@ -51,6 +53,7 @@ import org.oppia.android.testing.junit.ParameterizedRobolectricTestRunner 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.util.caching.AssetModule import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider import org.oppia.android.util.locale.LocaleProdModule @@ -87,6 +90,7 @@ class AppStartupStateControllerTest { @Inject lateinit var platformParameterController: PlatformParameterController @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers @Inject lateinit var monitorFactory: DataProviderTestMonitor.Factory + @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger @Parameter lateinit var initialFlavorName: String // TODO(#3792): Remove this usage of Locale (probably by introducing a test utility in the locale @@ -122,6 +126,18 @@ class AppStartupStateControllerTest { assertThat(mode.startupMode).isEqualTo(USER_NOT_YET_ONBOARDED) } + @Test + fun testController_afterSettingAppOnboarded_logsCompletedOnboardingEvent() { + setUpDefaultTestApplicationComponent() + appStartupStateController.markOnboardingFlowCompleted() + testCoroutineDispatchers.runCurrent() + + val event = fakeAnalyticsEventLogger.getMostRecentEvent() + assertThat(event.priority).isEqualTo(EventLog.Priority.OPTIONAL) + assertThat(event.context.activityContextCase) + .isEqualTo(EventLog.Context.ActivityContextCase.COMPLETE_APP_ONBOARDING) + } + @Test fun testController_settingAppOnboarded_observedNewController_userOnboardedApp() { // Simulate the previous app already having completed onboarding. @@ -1063,7 +1079,7 @@ class AppStartupStateControllerTest { ExpirationMetaDataRetrieverModule::class, // Use real implementation to test closer to prod. LoggingIdentifierModule::class, ApplicationLifecycleModule::class, SyncStatusModule::class, PlatformParameterModule::class, - PlatformParameterSingletonModule::class + PlatformParameterSingletonModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel index 16993f30a2c..7d59aa2021f 100644 --- a/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/onboarding/BUILD.bazel @@ -28,6 +28,7 @@ oppia_android_test( "//third_party:org_mockito_mockito-core", "//third_party:org_robolectric_robolectric", "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/locale:prod_module", "//utility/src/main/java/org/oppia/android/util/logging:prod_module", "//utility/src/main/java/org/oppia/android/util/networking:debug_module", 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/BUILD.bazel b/scripts/BUILD.bazel index 6ef9a5d6739..9577dfe834b 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -237,6 +237,15 @@ kt_jvm_binary( ], ) +kt_jvm_binary( + name = "wiki_table_of_contents_check", + testonly = True, + main_class = "org.oppia.android.scripts.wiki.WikiTableOfContentsCheckKt", + runtime_deps = [ + "//scripts/src/java/org/oppia/android/scripts/wiki:wiki_table_of_contents_check_lib", + ], +) + kt_jvm_binary( name = "run_coverage", testonly = True, 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 411037cf8c2..2a83c6f4b3d 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -1330,6 +1330,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 @@ -2366,6 +2370,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 @@ -2398,6 +2406,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/scripts/src/java/org/oppia/android/scripts/wiki/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/wiki/BUILD.bazel new file mode 100644 index 00000000000..6898d1b8c21 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/wiki/BUILD.bazel @@ -0,0 +1,18 @@ +""" +Libraries corresponding to scripting tools that help with continuous integration workflows. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") + +kt_jvm_library( + name = "wiki_table_of_contents_check_lib", + testonly = True, + srcs = [ + "WikiTableOfContentsCheck.kt", + ], + visibility = ["//scripts:oppia_script_binary_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:bazel_client", + "//scripts/src/java/org/oppia/android/scripts/common:git_client", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/wiki/WikiTableOfContentsCheck.kt b/scripts/src/java/org/oppia/android/scripts/wiki/WikiTableOfContentsCheck.kt new file mode 100644 index 00000000000..2f5a0ae3862 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/wiki/WikiTableOfContentsCheck.kt @@ -0,0 +1,82 @@ +package org.oppia.android.scripts.wiki + +import java.io.File + +/** + * Script for ensuring that the table of contents in each wiki page matches with its respective headers. + * + * Usage: + * bazel run //scripts:wiki_table_of_contents_check -- + * + * Arguments: + * - path_to_default_working_directory: The default working directory on the runner for steps, and the default location of repository. + * + * Example: + * bazel run //scripts:wiki_table_of_contents_check -- $(pwd) + */ +fun main(vararg args: String) { + // Path to the repo's wiki. + val wikiDirPath = "${args[0]}/wiki/" + val wikiDir = File(wikiDirPath) + + // Check if the wiki directory exists. + if (wikiDir.exists() && wikiDir.isDirectory) { + processWikiDirectory(wikiDir) + println("WIKI TABLE OF CONTENTS CHECK PASSED") + } else { + println("No contents found in the Wiki directory.") + } +} + +private fun processWikiDirectory(wikiDir: File) { + wikiDir.listFiles()?.forEach { file -> + checkTableOfContents(file) + } +} + +private fun checkTableOfContents(file: File) { + val fileContents = file.readLines() + val tocStartIdx = fileContents.indexOfFirst { + it.contains(Regex("""##\s+Table\s+of\s+Contents""", RegexOption.IGNORE_CASE)) + } + if (tocStartIdx == -1) { + return + } + + // Skipping the blank line after the ## Table of Contents + val tocEndIdx = fileContents.subList(tocStartIdx + 2, fileContents.size).indexOfFirst { + it.startsWith("#") + }.takeIf { it != -1 } + ?: error("Wiki doesn't contain headers referenced in Table of Contents.") + + val tocSpecificLines = fileContents.subList(tocStartIdx, tocStartIdx + tocEndIdx + 1) + + for (line in tocSpecificLines) { + if (line.trimStart().startsWith("- [") && !line.contains("https://")) { + validateTableOfContents(file, line) + } + } +} + +private fun validateTableOfContents(file: File, line: String) { + val titleRegex = "\\[(.*?)\\]".toRegex() + val title = titleRegex.find(line)?.groupValues?.get(1)?.replace('-', ' ') + ?.replace(Regex("[?&./:’'*!,(){}\\[\\]+]"), "") + ?.trim() + + val linkRegex = "\\(#(.*?)\\)".toRegex() + val link = linkRegex.find(line)?.groupValues?.get(1)?.removePrefix("#")?.replace('-', ' ') + ?.replace(Regex("[?&./:’'*!,(){}\\[\\]+]"), "") + ?.trim() + + // Checks if the table of content title matches with the header link text. + val matches = title.equals(link, ignoreCase = true) + if (!matches) { + error( + "\nWIKI TABLE OF CONTENTS CHECK FAILED" + + "\nMismatch of Table of Content with headers in the File: ${file.name}. " + + "\nThe Title: '${titleRegex.find(line)?.groupValues?.get(1)}' " + + "doesn't match with its corresponding Link: '${linkRegex.find(line)?.groupValues?.get(1)}'." + ) + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/wiki/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/wiki/BUILD.bazel new file mode 100644 index 00000000000..953b3f7d8d9 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/wiki/BUILD.bazel @@ -0,0 +1,16 @@ +""" +Tests corresponding to wiki-related checks. +""" + +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_test") + +kt_jvm_test( + name = "WikiTableOfContentsCheckTest", + srcs = ["WikiTableOfContentsCheckTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/wiki:wiki_table_of_contents_check_lib", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + "//third_party:org_jetbrains_kotlin_kotlin-test-junit", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/wiki/WikiTableOfContentsCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/wiki/WikiTableOfContentsCheckTest.kt new file mode 100644 index 00000000000..d90593dfa2e --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/wiki/WikiTableOfContentsCheckTest.kt @@ -0,0 +1,190 @@ +package org.oppia.android.scripts.wiki + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.testing.assertThrows +import java.io.ByteArrayOutputStream +import java.io.PrintStream + +/** Tests for [WikiTableOfContentsCheck]. */ +class WikiTableOfContentsCheckTest { + private val outContent: ByteArrayOutputStream = ByteArrayOutputStream() + private val originalOut: PrintStream = System.out + private val WIKI_TOC_CHECK_PASSED_OUTPUT_INDICATOR = "WIKI TABLE OF CONTENTS CHECK PASSED" + private val WIKI_TOC_CHECK_FAILED_OUTPUT_INDICATOR = "WIKI TABLE OF CONTENTS CHECK FAILED" + + @field:[Rule JvmField] val tempFolder = TemporaryFolder() + + @Before + fun setUp() { + System.setOut(PrintStream(outContent)) + } + + @After + fun tearDown() { + System.setOut(originalOut) + } + + @Test + fun testWikiTOCCheck_noWikiDirExists_printsNoContentFound() { + runScript() + assertThat(outContent.toString().trim()).isEqualTo("No contents found in the Wiki directory.") + } + + @Test + fun testWikiTOCCheck_noWikiDirectory_printsNoContentFound() { + tempFolder.newFile("wiki") + runScript() + assertThat(outContent.toString().trim()).isEqualTo("No contents found in the Wiki directory.") + } + + @Test + fun testWikiTOCCheck_validWikiTOC_checkPass() { + tempFolder.newFolder("wiki") + val file = tempFolder.newFile("wiki/wiki.md") + file.writeText( + """ + ## Table of Contents + + - [Introduction](#introduction) + - [Usage](#usage) + + ## Introduction + Content + + ## Usage + Content + """.trimIndent() + ) + + runScript() + + assertThat(outContent.toString().trim()).contains(WIKI_TOC_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testWikiTOCCheck_missingWikiTOC_returnsNoTOCFound() { + tempFolder.newFolder("wiki") + val file = tempFolder.newFile("wiki/wiki.md") + file.writeText( + """ + - [Introduction](#introduction) + - [Usage](#usage) + + ## Introduction + Content + + ## Usage + Content + """.trimIndent() + ) + + runScript() + + assertThat(outContent.toString().trim()).contains(WIKI_TOC_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testWikiTOCCheck_wikiTOCReference_noHeadersFound_throwsException() { + tempFolder.newFolder("wiki") + val file = tempFolder.newFile("wiki/wiki.md") + file.writeText( + """ + ## Table of Contents + + - [Introduction](#introductions) + + """.trimIndent() + ) + + val exception = assertThrows() { + runScript() + } + + assertThat(exception).hasMessageThat().contains( + "Wiki doesn't contain headers referenced in Table of Contents." + ) + } + + @Test + fun testWikiTOCCheck_mismatchWikiTOC_checkFail() { + tempFolder.newFolder("wiki") + val file = tempFolder.newFile("wiki/wiki.md") + file.writeText( + """ + ## Table of Contents + + - [Introduction](#introductions) + - [Usage](#usage) + + ## Introduction + Content + + ## Usage + Content + """.trimIndent() + ) + + val exception = assertThrows() { + runScript() + } + + assertThat(exception).hasMessageThat().contains(WIKI_TOC_CHECK_FAILED_OUTPUT_INDICATOR) + } + + @Test + fun testWikiTOCCheck_validWikiTOCWithSeparator_checkPass() { + tempFolder.newFolder("wiki") + val file = tempFolder.newFile("wiki/wiki.md") + file.writeText( + """ + ## Table of Contents + + - [Introduction To Wiki](#introduction-to-wiki) + - [Usage Wiki-Content](#usage-wiki-content) + + ## Introduction + Content + + ## Usage + Content + """.trimIndent() + ) + + runScript() + + assertThat(outContent.toString().trim()).contains(WIKI_TOC_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testWikiTOCCheck_validWikiTOCWithSpecialCharacter_checkPass() { + tempFolder.newFolder("wiki") + val file = tempFolder.newFile("wiki/wiki.md") + file.writeText( + """ + ## Table of Contents + + - [Introduction](#introduction?) + - [Usage?](#usage) + + ## Introduction + Content + + ## Usage + Content + """.trimIndent() + ) + + runScript() + + assertThat(outContent.toString().trim()).contains(WIKI_TOC_CHECK_PASSED_OUTPUT_INDICATOR) + } + + private fun runScript() { + main(tempFolder.root.absolutePath) + } +} 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() diff --git a/wiki/Guidance-on-submitting-a-PR.md b/wiki/Guidance-on-submitting-a-PR.md index 0124b44eb90..37b731e6f64 100644 --- a/wiki/Guidance-on-submitting-a-PR.md +++ b/wiki/Guidance-on-submitting-a-PR.md @@ -22,7 +22,7 @@ Note: If your change involves more than around 500 lines of code, we recommend f - [Tips for getting your PR submitted](#tips-for-getting-your-pr-submitted) - [Appendix: Resolving merge conflicts using the terminal](#appendix-resolving-merge-conflicts-using-the-terminal) - [Appendix: Resolving merge conflicts using Android Studio](#appendix-resolving-merge-conflicts-using-android-studio) -- [Step 4: Tidy up and celebrate!](#step-4-tidy-up-and-celebrate-confetti_ball) +- [Step 4: Tidy up and celebrate! :confetti_ball:](#step-4-tidy-up-and-celebrate-confetti_ball) ## Step 1: Making a local code change diff --git a/wiki/Installing-Oppia-Android.md b/wiki/Installing-Oppia-Android.md index ffc19cba8bd..dcf0a4baafb 100644 --- a/wiki/Installing-Oppia-Android.md +++ b/wiki/Installing-Oppia-Android.md @@ -7,7 +7,7 @@ This wiki page explains how to install Oppia Android on your local machine. If y - [Prepare developer environment](#prepare-developer-environment) - [Install oppia-android](#install-oppia-android) - [Run the app from Android Studio](#run-the-app-from-android-studio) -- [Run the tests](#set-up-and-run-tests) +- [Set up and Run tests](#set-up-and-run-tests) - [Step-by-Step guidance for setting up and running app modules robolectric test](#step-by-step-guidance-for-setting-up-and-running-app-modules-robolectric-test) - [For tests that are in non-app modules, such as **domain** or **utility**:](#for-tests-that-are-in-non-app-modules-such-as-domain-or-utility) diff --git a/wiki/Interpreting-CI-Results.md b/wiki/Interpreting-CI-Results.md index 2adf2685b53..00b3c5667c7 100644 --- a/wiki/Interpreting-CI-Results.md +++ b/wiki/Interpreting-CI-Results.md @@ -1,6 +1,6 @@ ## Table of Contents -- [How to find the error message for a Failing CI check](#how-to-find-error-message-for-failing-ci-checks) +- [How to find error message for Failing CI checks](#how-to-find-error-message-for-failing-ci-checks) - [Developer Video - Understanding CI check failures](#developer-video---understanding-ci-check-failures) ## How to find error message for Failing CI checks diff --git a/wiki/Oppia-Android-Code-Coverage.md b/wiki/Oppia-Android-Code-Coverage.md index 499b3591642..0d294887e8f 100644 --- a/wiki/Oppia-Android-Code-Coverage.md +++ b/wiki/Oppia-Android-Code-Coverage.md @@ -4,10 +4,10 @@ - [Understanding Code Coverage](#understanding-code-coverage) - [Why is Code Coverage Important?](#why-is-code-coverage-important) - [How to use the code coverage tool?](#how-to-use-the-code-coverage-tool) - - [Continuous Itegration Checks on Pull Request](#1-continuous-integration-checks-on-pull-requests) - - [Understanding the CI Coverage Report](#11-understanding-the-ci-coverage-report) - - [Local Command Line Interface Tools](#2-local-command-line-interface-cli-tools) - - [Understanding the Html Reports](#21-understanding-the-ci-coverage-report) + - [1. Continuous Integration Checks on Pull Requests](#1-continuous-integration-checks-on-pull-requests) + - [1.1 Understanding the CI Coverage Report](#11-understanding-the-ci-coverage-report) + - [2. Local Command Line Interface (CLI) Tools](#2-local-command-line-interface-cli-tools) + - [2.1 Understanding the CI Coverage Report](#21-understanding-the-ci-coverage-report) - [Increasing Code Coverage Metrics](#increasing-code-coverage-metrics) - [Unit-Centric Coverage Philosophy](#unit-centric-coverage-philosophy) - [Limitations of the code coverage tool](#limitations-of-the-code-coverage-tool) diff --git a/wiki/Upgrading-Target-Sdk-Guide.md b/wiki/Upgrading-Target-Sdk-Guide.md new file mode 100644 index 00000000000..bc5ce02e032 --- /dev/null +++ b/wiki/Upgrading-Target-Sdk-Guide.md @@ -0,0 +1,156 @@ +## Overview + +Updating Oppia Android's [target SDK](https://developer.android.com/guide/topics/manifest/uses-sdk-element#target) provides an explicit signal to Android OS versions at or above the new target SDK level that the app should work correctly for that platform. + +The target SDK version, unlike the compile SDK version, is specifically a runtime behavior signal. That means it enables functionality only observable by opening and running the app on the corresponding, or newer, versions of the OS. When Android makes changes that could break compatibility with older apps, they will usually gate this behind the target SDK level so that apps can have time to upgrade without users being unable to use them after they themselves upgrade to the newer Android version. Not every version of Android introduces these compatibility breakages, and not every potential breakage will affect Oppia Android. + +This guide describes the high-level process for upgrading the app to a newer version of Android, and how to do it in a way that should reduce the risk of introducing breakages to users. + +## Upgrade Process + +```mermaid +flowchart TD + classDef textWrap text-wrap: wrap; + A(Step 1: Identifying the need to upgrade):::textWrap -->|Tracking issue exists| B + B(Step 2: Auditing the Android OS changelog):::textWrap -->|Audit complete| C + C(Step 3: Testing the app & filing problems):::textWrap -->|Testing finished & issues filed| D + D(Step 4: Fixing and stabilizing support):::textWrap -->|All issues fixed| E + E(Step 5: Submitting the upgrade):::textWrap -->|Compile/target SDK PR submitted| F + F(Step 6: Future work items and upgrading Robolectric):::textWrap -->|Future issues filed| G + G(Finished) +``` + +### Step 1: Identifying the need to upgrade + +There are generally three signals that may indicate the team should consider upgrading to a newer Android SDK target: +1. https://developer.android.com/google/play/requirements/target-sdk indicates an upgrade mandate and deadline. We also get this reminder via the Google Play Console. +2. A new version of Android has released to users and we perform a periodic check for compatibility (usually around July/August). We may decide to upgrade even if there isn't a mandate. +3. A feature requires a newer version of Android (note this is unlikely since we generally want to design features to work for all of our users). + +If it's deemed that there's a new SDK version to target and the app isn't yet targeting it, a new feature request should be filed (similar to [#5535](https://github.com/oppia/oppia-android/pull/5535)) as long as there isn't an existing tracking issue for this work. + +### Step 2: Auditing the Android OS changelog + +All new Android OS functionality changes (both those tied to ``targetSdkVersion`` and those not) should be analyzed for potential areas of testing. These can be found on the Android developers site, for example for SDK 34: +- Changes affecting all apps: https://developer.android.com/about/versions/14/behavior-changes-all. +- Changes tied to changing target SDK version: https://developer.android.com/about/versions/14/behavior-changes-14. + +Any concerning changes or functionality that could be beneficial to Oppia Android should be noted in the tracking issue for the SDK upgrade (see https://github.com/oppia/oppia-android/issues/5137#issuecomment-1815241974 for a good example of this). + +#### Step 2.1: Tips for auditing +Note that narrowing down these categories isn't a process that can be easily described as a set of steps since new OS features may not even be predictable ahead of time. However, here are some tips that might help: +- Look for changes in permissions. This could either be an old permission that's now more restricted, or existing SDK functionality that's now blocked by a new permission (both have occurred in past Android OS updates). If the functionality and/or permission relates to Oppia Android, it should be noted. Note that some things may be tied to permissions and access control that's not obvious such as: + - Filesystem management + - Content providers (such as for photo selection which we use for users selecting their avatar) + - Clipboard management (which we support for a specific user study feature) +- Look for changes in service/worker management, especially background processing (note that Oppia Android does not use a foreground service) and wakelocks. These areas receive updates in almost every OS version and Oppia Android relies in them indirectly (via Firebase and ``WorkManager``). +- Changes in SQLite database support _could_ affect the app, so it's worth noting. +- API deprecations should always be checked against the latest Oppia Android ``develop`` code and, in cases where we are using those APIs, be noted as this is likely to become a compiler error. +- Changes in media handling, especially for ``MediaPlayer``. +- Changes in UI lifecycle management (such as in a past OS version when Android introduced the support for multiple apps to be started, but not resumed, at the same time, e.g. for split screen). +- Generic changes that may affect any of the ~100 third-party dependencies the app uses. A good example of this: https://developer.android.com/about/versions/13/changes/non-sdk-13. + +For anything else, if you're unsure whether it affects Oppia Android then err on the side of noting it rather than ignoring it. The expectation is that whoever goes through this step of the process will read _every_ listed change in the new target version of Android and note **everything** of interest. + +#### Step 2.2: Reporting findings + +Post the findings as a new comment in the tracking issue using three different lists: +1. One list for areas with known problems (e.g. API deprecations). Note that each of these problems should be filed as separate bugs in the Oppia Android issue tracker and their issue numbers noted as part of this list. +2. One list for areas that require additional verification to ensure compatibility. +3. One list for areas that could be of interest for future work. + +The new comment should include an explicit indication of whether the audit was completed, or if additional analysis on Android SDK documentation is needed. + +### Step 3: Testing the app & filing problems + +Compatibility with the new target SDK should be done by: +1. Building a local production [Bazel build](https://github.com/oppia/oppia-android/wiki/Oppia-Bazel-Setup-Instructions#building-the-app) of the app (``//:oppia_beta``) and deployed to a local emulator or device running the **same** version of Android as the new target SDK version. + - Note that this requires the local app's target SDK to be temporarily upgraded (see step (5) below) but not checked in. + - In some cases, a real device may need to be used instead of an emulator since certain features change behavior on an emulator (such as the drag and drop interaction). + - If the app isn't already using the new version of Android as its compile SDK version, then it may fail to build. Any build failures should be filed as issues on the issue tracker and fixed before this step of the process can continue. +2. Testing the app using the local production build of the app to ensure compatibility (see the following sub-sections for specifics). + +#### Step 3.1: Testing potential problem areas + +The list of focus areas to specifically test (per the audit completed in step (2) above) should be explicity tested to ensure that corresponding user features behave correctly and don't have new issues due to the SDK change. +- Note that in some cases this may require using both a handset and tablet emulator configuration if there are tablet-specific or layout-specific areas identified. +- Note that [#5137](https://github.com/oppia/oppia-android/issues/5137) may provide some good context on how to test certain types of changes that may not be as simple as manually performing a certain user action and may instead require a clever code change. + +#### Step 3.2: Testing broad app behaviors + +A general analysis should be peformed by testing the following scenarios: +- Profile creation/deletion and login. +- Playing, pausing, resuming, and finishing a lesson. +- Ensuring all interactions work (play through every test topic prototype exploration). +- Ensuring LaTeX and in-lesson images load correctly. +- Ensuring that lesson progress correctly saves per profile. +- Ensuring that profile avatars can be correctly set. +- Ensuring that hints and solutions work correctly. +- Ensuring that wrong answers are handled correctly. +- Checking that concept and revision cards work correctly. +- Verifying that events are logged (either per Firebase analytics if you have access, or the developer options menu using a build of ``//:oppia_dev``--note that ``oppia_dev`` should only be used for this specific verification and not any of the others). + +#### Step 3.3: Cataloging findings + +Any breakages should be noted, and then checked against a version of the app without the target SDK. From there: +- If the breakage still occurs, file a new bug noting the problem and mention in the 'additional context' section that it was found during target SDK testing but was determined as unrelated. +- If the breakage does not occur on the non-upgraded version of the app, file a new bug in the issue tracker and mention that it's specific to the new target SDK version and is a blocking issue. + +Please note all found blocking issues with their issue numbers in a follow-up reply to the tracking issue. Any other thoughts or findings during testing can also be noted in the tracking issue (similar to the comments in [#5137](https://github.com/oppia/oppia-android/issues/5137)). Please also note in the tracking issue when testing has concluded. + +### Step 4: Fixing and stabilizing support + +Work on fixing all identified problems from steps (3) and (4) (either by directly fixing the problems via code changes, or via coordination with other members of the team). + +Once all issues are fixed, verify each problem is correctly addressed using a temporary local build of the app (see step (3) above). If any problems are still occurring, reopen the corresponding tracking issue and leave a follow-up comment detailing the ongoing problem and steps to reproduce it. + +Once all fixed issues are verified, leave a follow-up comment on the upgrade target SDK tracking issue mentioning that verification has concluded and there are no remaining issues found. + +### Step 5: Submitting the upgrade + +The actual code change to upgrade the app comes in two parts: +1. Upgrading the compile version (which may already be done as the team sometimes needs to update this for other reasons). +2. Upgrading the runtime target SDK version. + +The sub-sections below detail each of the code changes needed to perform these upgrades. + +**Important caveats and notes**: +- Both version upgrades can be done together in the same PR, but if they are split up the compile-time change (step (5.1) below) must happen first. +- [#5222](https://github.com/oppia/oppia-android/issues/5222) is an example of a PR that performs both steps in one, though it includes a few additional code changes that were needed as a result of the compile SDK change. +- Please note the CI results for this upgrade change. Any failures are likely problems that will need to be fixed within the upgrade PR (if small, e.g. the change in [#5222](https://github.com/oppia/oppia-android/issues/5222)) or filed as a separate bug that will need to be fixed before the upgrade can be submitted (see step (4) above). +- Updating the compile-time SDK version may require updating the build tools version. This is **not** a simple change and may cause difficult-to-fix breakages due to subtle compatibility issues between third-party dependencies and the build system configurations. If you suspect a build tools version upgrade is needed, please file an issue to track it and contact the developer workflow team lead to discuss next steps. +- Updating tests to use a newer version of Android can be exceptionally complicated, and thus this is considered a completely separate exercise from upgrading production code. See step (6) below for more specifics. + +#### Step 5.1: Updating the compiled SDK version + +This code change essentially requires replacing the old SDK version number (e.g. 31) with the new one (e.g. 33), but only for compile-time behaviors. All needed changes are detailed below: +1. [``.github/actions/set-up-android-bazel-build-environment/action.yml``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/.github/actions/set-up-android-bazel-build-environment/action.yml#L75-L78) needs to be updated to install the correct SDK version (via the ``sdkmanager --install`` command). +2. Bazel [``build_vars.bzl``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/build_vars.bzl#L1) changes to ``BUILD_SDK_VERSION``. +3. Gradle ``compileSdkVersion`` changes (e.g. for [``app/build.gradle``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/app/build.gradle#L8)). Note that all module ``.gradle`` files will need to be updated in this way. + +#### Step 5.2: Updating the target SDK version + +This code change requires changing Bazel, Gradle, and ``AndroidManifest.xml`` files. All needed changes are detailed below: +1. The top-level [``BUILD.bazel``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/BUILD.bazel#L118-L130)'s APK targets need to be upgraded to target the correct SDK. +2. All Bazel AAB targets need to be updated in [``build_flavors.bzl``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/build_flavors.bzl#L45-L146)'s ``_FLAVOR_METADATA`` dict to point to the correct target SDK version (each flavor has its own target SDK declared). +3. All manifest XML files (e.g. [``app/src/main/AppAndroidManifest.xml``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/app/src/main/AppAndroidManifest.xml#L4)) that _have_ an ``android:targetSdkVersion`` attribute need to be updated to use the correct version. If a manifest file is missing this attribute, it doesn't need to be changed. +4. All module ``.gradle`` files must be updated to use the correct target SDK version, e.g. [``app/build.gradle``](https://github.com/oppia/oppia-android/blob/dfb9a301280b9a46526cb2f5ca6329532fec6bf0/app/build.gradle#L13). + +After the four areas above are completed, the old SDK version is unlikely to be present anywhere in the codebase. This can be verified using a quick "find all" or ``grep`` search. One likely exception is tests (see the caveats list in the main section of step (5) above). + +The PR that updates the target SDK version can be marked as fixing and closing the corresponding tracking issue. + +### Step 6: Future work items and upgrading Robolectric + +Findings from the analysis in step (2) should be considered as potential future work items. Anything that either the CLaM or developer workflow team leads think might be worth pursuing in the future should be filed as feature requests in the issue tracker and mentioned in a follow-up comment in the SDK upgrade tracking issue. + +Ideally, Robolectric tests would also be upgraded with the target SDK version. However, there are a few problems with this currently: +1. Robolectric's version is tightly coupled with the SDKs it supports (since Robolectric itself needs to be updated to support each version of Android). +2. Robolectric usually lags far behind (sometimes more than a year) mainline Android for SDK support. +3. Upgrading Robolectric can have significant downstream effects. One such case that's been observed in the past: + - Upgrading Robolectric required upgrading Espresso (since Robolectric depends on Espresso libraries to implement part of its API). + - Upgrading Espresso required upgrading AndroidX libraries (which actually impact production behaviors). + - Upgrading the AndroidX libraries led to many other version upgrades that actually eventually led to a Kotlin version upgrade and an upgrade to the version of Bazel used. +4. Robolectric does not have strong behavior consistency between SDK versions so tests have a relatively higher chance of regressing when changing the SDK version Robolectric is using by default than production code. + +For now, the best course of action is to either file a new feature request to upgrade Robolectric tests to use the same target SDK as the app by default, or update the existing issue if there's one already tracking an upgrade (which is likely since the upgrade can be both difficult and time consuming, so it's usually not a team priority). diff --git a/wiki/Writing-tests-with-good-behavioral-coverage.md b/wiki/Writing-tests-with-good-behavioral-coverage.md index db50cfa83a6..93b94ac7375 100644 --- a/wiki/Writing-tests-with-good-behavioral-coverage.md +++ b/wiki/Writing-tests-with-good-behavioral-coverage.md @@ -3,18 +3,18 @@ - [What is Behavioral Coverage?](#what-is-behavioral-coverage) - [Understanding Behavioral Coverage and its Importance](#understanding-behavioral-coverage-and-its-importance) - [Writing Effective Tests](#writing-effective-tests) - - [Understand the Requirements](#1-understanding-the-requirements) - - [Writing Clear and Descriptive Test Cases](#2-writing-clear-and-descriptive-test-cases) - - [Focusing on Specific Test Cases](#3-focusing-on-specific-test-cases) - - [Covering Different Scenarios](#4-covering-different-scenarios) - - [Covering All Branches, Paths, and Conditions](#5-covering-all-branches-paths-and-conditions) - - [Exception and Error Handling](#6-exception-and-error-handling) - - [Absence of Unwanted Output](#7-absence-of-unwanted-output) + - [1. Understanding the Requirements](#1-understanding-the-requirements) + - [2. Writing Clear and Descriptive Test Cases](#2-writing-clear-and-descriptive-test-cases) + - [3. Focusing on Specific Test Cases](#3-focusing-on-specific-test-cases) + - [4. Covering Different Scenarios](#4-covering-different-scenarios) + - [5. Covering All Branches, Paths, and Conditions](#5-covering-all-branches-paths-and-conditions) + - [6. Exception and Error Handling](#6-exception-and-error-handling) + - [7. Absence of Unwanted Output](#7-absence-of-unwanted-output) - [Testing Public APIs](#testing-public-apis) - [Structuring Test Bodies](#structuring-test-bodies) - - [When and How to Divide Responsibilities](#1-when-and-how-to-divide-responsibilities) - - [When Not to Divide Responsibilities](#2-when-not-to-divide-responsibilities) - - [Importance of Descriptive Test Names](#3-importance-of-descriptive-test-names) + - [1. When and How to Divide Responsibilities](#1-when-and-how-to-divide-responsibilities) + - [2. When Not to Divide Responsibilities](#2-when-not-to-divide-responsibilities) + - [3. Importance of Descriptive Test Names](#3-importance-of-descriptive-test-names) - [How to Map a Line of Code to Its Corresponding Behaviors?](#how-to-map-a-line-of-code-to-its-corresponding-behaviors) # What is Behavioral Coverage? diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index c1f9834bc49..c6639f04b52 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -29,14 +29,14 @@ * [Writing Tests with Good Behavioral Coverage](https://github.com/oppia/oppia-android/wiki/Writing-Tests-With-Good-Behavioral-Coverage) * [Developing Skills](https://github.com/oppia/oppia-android/wiki/Developing-skills) * [Frequent Errors and Solutions](https://github.com/oppia/oppia-android/wiki/Frequent-Errors-and-Solutions) - * [RTL Guidelines](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines) + * [RTL Guidelines](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines) * [Working on UI](https://github.com/oppia/oppia-android/wiki/Working-on-UI) * [Writing Design Docs](https://github.com/oppia/oppia-android/wiki/Writing-design-docs) --- **Developer Reference** * Code style * [Coding style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide) - * [Ktlint Guide](https://github.com/oppia/oppia-android/wiki/Ktlint-Guide) + * [Ktlint Guide](https://github.com/oppia/oppia-android/wiki/Ktlint-Guide) * [Static Analysis Checks](https://github.com/oppia/oppia-android/wiki/Static-Analysis-Checks) * [Accessibility Guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide) * [Debugging](https://github.com/oppia/oppia-android/wiki/Debugging) @@ -45,7 +45,7 @@ * [Background Processing](https://github.com/oppia/oppia-android/wiki/Background-Processing) * [Kotlin Coroutines](https://github.com/oppia/oppia-android/wiki/Kotlin-Coroutines) * [DataProvider & LiveData](https://github.com/oppia/oppia-android/wiki/DataProvider-&-LiveData) - * [PersistentCacheStore & In Memory Blocking Cache](https://github.com/oppia/oppia-android/wiki/PersistentCacheStore-&-In-Memory-Blocking-Cache) + * [PersistentCacheStore & In Memory Blocking Cache](https://github.com/oppia/oppia-android/wiki/PersistentCacheStore-&-In-Memory-Blocking-Cache) * [Dark mode](https://github.com/oppia/oppia-android/wiki/Dark-Mode) * [Buf Guide](https://github.com/oppia/oppia-android/wiki/Buf-Guide) * [Firebase Console Guide](https://github.com/oppia/oppia-android/wiki/Firebase-Console-Guide) @@ -53,10 +53,11 @@ * [Work Manager](https://github.com/oppia/oppia-android/wiki/Work-Manager) * [Dependency Injection](https://github.com/oppia/oppia-android/wiki/Dependency-Injection) with [Dagger](https://github.com/oppia/oppia-android/wiki/Dagger) * [Revert & regression policy](https://github.com/oppia/oppia-android/wiki/Revert-&-regression-policy) + * [Upgrading target SDK version](https://github.com/oppia/oppia-android/wiki/Upgrading-Target-Sdk-Guide) * [Spotlight Guide](https://github.com/oppia/oppia-android/wiki/Spotlight-Guide) * [Triaging Process](https://github.com/oppia/oppia-android/wiki/Triaging-process) * Bazel - * [Gradle Bazel Migration Best Practices and FAQ](https://github.com/oppia/oppia-android/wiki/Gradle--Bazel-Migration-Best-Practices-and-FAQ) + * [Gradle Bazel Migration Best Practices and FAQ](https://github.com/oppia/oppia-android/wiki/Gradle--Bazel-Migration-Best-Practices-and-FAQ) * [Updating Maven Dependencies](https://github.com/oppia/oppia-android/wiki/Updating-Maven-Dependencies) * [Internationalization](https://github.com/oppia/oppia-android/wiki/Internationalization) * [Terminology in Oppia](https://github.com/oppia/oppia-android/wiki/Terminology-in-Oppia)