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/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/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"> + + + + diff --git a/app/src/main/res/drawable/splash_page.xml b/app/src/main/res/drawable/splash_page.xml index 96790799d5d..a95dba7e5eb 100644 --- a/app/src/main/res/drawable/splash_page.xml +++ b/app/src/main/res/drawable/splash_page.xml @@ -3,12 +3,6 @@ android:height="640dp" android:viewportWidth="360" android:viewportHeight="640"> - - - - diff --git a/app/src/main/res/layout-land/audio_language_selection_fragment.xml b/app/src/main/res/layout-land/audio_language_selection_fragment.xml index ed683db064e..28cc53ec8f1 100644 --- a/app/src/main/res/layout-land/audio_language_selection_fragment.xml +++ b/app/src/main/res/layout-land/audio_language_selection_fragment.xml @@ -3,6 +3,13 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:card_view="http://schemas.android.com/apk/res-auto"> + + + + + + 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-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 81bca583bed..37238d2f821 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -4,10 +4,11 @@ * Meno25 * MohamedMedhat * Mouradeq +* SamarBenNafa * Sarah zoubida * Seanlip --> - + أعلى قائمة التنقل خيارات التنزيلات @@ -167,7 +168,7 @@ لا يجب أن يحتوي أي رقم في الكسر العشري على أكثر من 7 أرقام. من فضلك إبدأ إجابتك برقم (0 او 0.5 على سبيل المثال) من فضلك قم بإدخال رقم صالح. - (–يمكن أن تحتوي الإجابة على 15 رقمًا (0-9) على الأكثر أو الرموز (. أو + (–يمكن أن تحتوي الإجابة على 15 رقمًا (0-9) على الأكثر أو الرموز (. أو من فضلك قم بكتابة نسبة تتكون من أرقام مفصولة بنقطتين رأسيتين (1:2 أو 1:2:3 على سبيل المثال). من فضلك أدخل نسبة صحيحة (1:2 أو 1:2:3 على سبيل المثال) إجابتك تحتوي على نقطتين رأسيتين (:) متتاليتين. @@ -411,7 +412,7 @@ عظيم هيّا نبدأ. نعم - …لا + …لا اختر موضوعًا\nآخرًا. هل أنت مهتم بـ:\n%s ملاحظة جديدة متاحة @@ -456,11 +457,11 @@ إشعار بيتا مرحبًا! يتم الآن تحديث تطبيقك إلى الإصدار التجريبي. إذا واجهت مشاكل أثناء استخدام التطبيق ، أو كانت لديك أسئلة ، فيرجى الاتصال بنا على android-feedback@oppia.org. لا تظهر هذه الرسالة مرة أخرى - نعم + نعم إشعار التوفر العام مرحبًا! يتم الآن تحديث تطبيقك إلى إصدار التوفر العام. إذا واجهت مشاكل أثناء استخدام التطبيق ، أو كانت لديك أسئلة ، فيرجى الاتصال بنا على android-feedback@oppia.org. لا تظهر هذه الرسالة مرة أخرى - نعم + نعم إلى أدخل نسبة في الصيغة س:ص. انقر هنا لإدخال نص. @@ -498,19 +499,31 @@ شروط الخدمة باستخدام %s ، فإنك توافق على<br><oppia-noninteractive-policy link=\"tos\"> شروط الخدمة</oppia-noninteractive-policy> و<oppia-noninteractive-policy link=\"privacy\"> سياسة الخصوصية</oppia-noninteractive-policy> . يرجى زيارة <a href=\"https://www.oppia.org/terms\">هذه الصفحة</a> للحصول على أحدث نسخة من هذه الشروط. - كيف يمكنني إنشاء ملف تعريف(حساب) جديد؟ - كيف يمكنني حذف ملف التعريف(حساب)؟ ما هي %s؟ من هو المشرف؟ + كيف يمكنني إنشاء ملف تعريف(حساب) جديد؟ + كيف يمكنني الحصول على التطبيق بلغتي؟ + وجدت خلل. كيف يمكنني الإبلاغ عنه؟ + لماذا هناك فقط دروس رياضيات؟ + هل ستقومون بإنشاء مزيد من الدروس؟ لماذا لا يتم تحميل مشغل الاستكشاف؟ لماذا لا يتم تشغيل الصوت الخاص بي؟ + كيف يمكنني حذف ملف التعريف(حساب)؟ + كيف يمكنني تحديث التطبيق؟ + كيف يمكنني تحديث نظام التشغيل Android الخاص بي؟ لا أجد سؤالي هنا. ماذا الان؟ - <p>إذا كانت هذه هي المرة الأولى التي تنشئ فيها ملفًا شخصيًا وليس لديك رقم تعريف شخصي: <ol><li> من منتقي الملف الشخصي ، اضغط على<strong> قم بإعداد ملفات تعريف متعددة</strong></li><li> قم بإنشاء رقم تعريف شخصي و<strong>احفظ</strong></li><li> املأ جميع البيانات للملف الشخصي.<ol><li>(اختياري) قم بتحميل صورة.</li> <li>إدخال اسم.</li> <li>(اختياري) قم بتعيين رقم تعريف شخصي مكون من 3 أرقام.</li></ol></li><li> اضغط<strong>إنشاء</strong> . تمت إضافة هذا الملف الشخصي إلى منتقي ملف التعريف الخاص بك!</li></ol></p><p> إذا قمت بإنشاء ملف تعريف من قبل ولديك رقم تعريف شخصي:<ol><li> من منتقي الملف الشخصي ، اضغط على<strong>إضافة الملف الشخصي</strong></li> <li> أدخل رقم التعريف الشخصي الخاص بك وانقر فوق<strong>إرسال</strong></li><li> املأ جميع الحقول للملف الشخصي. <ol> <li>(اختياري) قم بتحميل صورة.</li> <li>إدخال اسم.</li> <li>(اختياري) قم بتعيين رقم تعريف شخصي مكون من 3 أرقام. </li></ol></li><li> اضغط<strong>إنشاء</strong> . تمت إضافة هذا الملف الشخصي إلى منتقي ملف التعريف الخاص بك!</li></ol></p><br><p> ملاحظة: فقط ال<u>مدير</u> قادر على إدارة الملفات الشخصية.</p> - <p>بمجرد حذف ملف التعريف:</p><ol><li>لا يمكن استعادة ملف التعريف.</li><li>سيتم حذف معلومات الملف الشخصي مثل الاسم والصور والتقدم بشكل دائم.</li></ol><p>لحذف ملف تعريف (باستثناء<u>المسؤول</u>):</p><ol><li> من الصفحة الرئيسية للمسؤول ، اضغط على زر القائمة أعلى اليسار.</li><li> اضغط على<strong>ضوابط المسؤول</strong>. </li><li> اضغط على<strong>تحرير ملفات التعريف</strong>.</li><li> اضغط على الملف الشخصي الذي ترغب في حذفه.</li><li> في الجزء السفلي من الشاشة ، انقر فوق<strong>حذف الملف الشخصي</strong>. </li><li> اضغط<strong>حذف</strong>لتأكيد الحذف. </li></ol><p><br></p><p> ملاحظة:<u>المسؤول</u>فقط هو القادر على إدارة الملفات الشخصية.</p> <p>%1$s <i>\"أو-بي-يا\"</i>(فنلندية) - \"للتعلم\"</p><p><br></p><p>%1$sمهمتنا هي مساعدة أي شخص على تعلم أي شيء يريده بطريقة فعالة وممتعة.</p><p><br></p><p>من خلال إنشاء مجموعة من الدروس المجانية عالية الجودة والفعالة بشكل واضح بمساعدة معلمين من جميع أنحاء العالم ، تهدف %1$s إلى تزويد الطلاب بتعليم جيد - بغض النظر عن مكان وجودهم أو الموارد التقليدية التي يمكنهم الوصول إليها.</p><p><br></p><p>كطالب ، يمكنك أن تبدأ مغامرتك التعليمية من خلال تصفح الموضوعات المدرجة في الصفحة الرئيسية!</p> - <p>المشرف هو المستخدم الرئيسي الذي يدير ملفات التعريف والإعدادات لكل ملف تعريف على حسابه. هم على الأرجح والدك أو معلمك أو وصي عليك الذي أنشأ هذا الملف الشخصي لك.</p><p><br></p><p>يمكن للمسؤولين إدارة الملفات الشخصية وتعيين أرقام التعريف الشخصية وتغيير الإعدادات الأخرى ضمن حساباتهم. بناءً على ملف التعريف الخاص بك ، قد تكون أذونات المسؤول مطلوبة لبعض الميزات مثل تنزيل الموضوعات وتغيير رقم التعريف الشخصي وغير ذلك.</p><p><br></p><p>لمعرفة من هو المسؤول لديك ، انتقل إلى منتقي الملف الشخصي. الملف الشخصي الأول المدرج ولديه \"المسؤول\" مكتوب باسمه هو المسؤول.</p> - <p>إذا لم يتم تحميل مشغل الاستكشاف</p><p><br></p><p>تحقق لمعرفة ما إذا كان التطبيق محدثًا أم لا:</p><p> <ul> <li> انتقل إلى متجر Play وتأكد من تحديث التطبيق إلى أحدث إصدار </li></ul><p><br></p><p>تحقق من اتصالك بالإنترنت:</p><ul><li> إذا كان اتصالك بالإنترنت بطيئًا ، فحاول إعادة الاتصال بشبكة Wi-Fi أو الاتصال بشبكة أخرى. </li></ul><p>اطلب من المشرف التحقق من أجهزتهم واتصال الإنترنت:</p><ul><li> اطلب من المشرف استكشاف الأخطاء وإصلاحها باستخدام الخطوات المذكورة أعلاه </li></ul><p>أخبرنا إذا كنت لا تزال تواجه مشكلات في التحميل:</p><ul><li> أبلغ عن مشكلة عن طريق الاتصال بنا على admin@oppia.org. </li></ul> - <p>إذا لم يتم تشغيل الصوت الخاص بك</p><p><br></p><p>تحقق لمعرفة ما إذا كان التطبيق محدثًا أم لا:</p><ul><li>انتقل إلى متجر Play وتأكد من تحديث التطبيق إلى أحدث إصدار</li></ul><p><br></p><p>تحقق من اتصالك بالإنترنت:</p><ul><li>إذا كان اتصالك بالإنترنت بطيئًا ، فحاول إعادة الاتصال بشبكة Wi-Fi أو الاتصال بشبكة أخرى. قد يتسبب الإنترنت البطيء في تحميل الصوت بشكل غير منتظم ، مما يجعل من الصعب تشغيله.</li></ul><p><br></p><p>اطلب من المسؤول التحقق من أجهزتهم واتصال الإنترنت:</p><ul><li>اطلب من المسؤول استكشاف الأخطاء وإصلاحها باستخدام الخطوات المذكورة أعلاه</li></ul><p><br></p><p>أخبرنا إذا كنت لا تزال تواجه مشكلات في التحميل:</p><ul><li>أبلغ عن مشكلة عن طريق الاتصال بنا على admin@oppia.org.</li></ul> + <p>المشرف هو المستخدم الرئيسي الذي يدير ملفات التعريف والإعدادات لكل ملف تعريف على حسابه. هم على الأرجح والدك أو معلمك أو وصي عليك الذي أنشأ هذا الملف الشخصي لك.</p><p><br></p><p>يمكن للمسؤولين إدارة الملفات الشخصية وتعيين أرقام التعريف الشخصية وتغيير الإعدادات الأخرى ضمن حساباتهم. بناءً على ملف التعريف الخاص بك ، قد تكون أذونات المسؤول مطلوبة لبعض الميزات مثل تنزيل الموضوعات وتغيير رقم التعريف الشخصي وغير ذلك.</p><p><br></p><p>لمعرفة من هو المسؤول لديك ، انتقل إلى منتقي الملف الشخصي. الملف الشخصي الأول المدرج ولديه \"المسؤول\" مكتوب باسمه هو المسؤول.</p> + <p>إذا كانت هذه هي المرة الأولى التي تنشئ فيها ملفًا شخصيًا وليس لديك رقم تعريف شخصي: <ol><li> من منتقي الملف الشخصي ، اضغط على<strong> قم بإعداد ملفات تعريف متعددة</strong></li><li> قم بإنشاء رقم تعريف شخصي و<strong>احفظ</strong></li><li> املأ جميع البيانات للملف الشخصي.<ol><li>(اختياري) قم بتحميل صورة.</li> <li>إدخال اسم.</li> <li>(اختياري) قم بتعيين رقم تعريف شخصي مكون من 3 أرقام.</li></ol></li><li> اضغط<strong>إنشاء</strong> . تمت إضافة هذا الملف الشخصي إلى منتقي ملف التعريف الخاص بك!</li></ol></p><p> إذا قمت بإنشاء ملف تعريف من قبل ولديك رقم تعريف شخصي:<ol><li> من منتقي الملف الشخصي ، اضغط على<strong>إضافة الملف الشخصي</strong></li> <li> أدخل رقم التعريف الشخصي الخاص بك وانقر فوق<strong>إرسال</strong></li><li> املأ جميع الحقول للملف الشخصي. <ol> <li>(اختياري) قم بتحميل صورة.</li> <li>إدخال اسم.</li> <li>(اختياري) قم بتعيين رقم تعريف شخصي مكون من 3 أرقام. </li></ol></li><li> اضغط<strong>إنشاء</strong> . تمت إضافة هذا الملف الشخصي إلى منتقي ملف التعريف الخاص بك!</li></ol></p><br><p> ملاحظة: فقط ال<u>مدير</u> قادر على إدارة الملفات الشخصية.</p> + <p>التطبيق %s يدعم حاليا اللغة الإنجليزية والبرتغالية البرازيلية والعربية والسواحيلية والبيدجينية النيجيرية. اختر إحدى هذه اللغات من القائمة، في الخيارات. لطلب التطبيق باللغة التي تتحدث بها، يرجى الاتصال بنا على <strong>admin@oppia.org</strong>.</p> + <p><ol><li>من خلال %s شاشة التطبيق الرئيسية، اضغط على القائمة في أعلى الزاوية اليسرى</li><li>اضغط على <strong>مشاركة التعليقات</strong>.</li><li>اتبع التعليمات للإبلاغ عن الأخطاء أو مشاركة التعليقات</li></ol></p> + <p>%1$s مهمتها مساعدة المتعلمين على اكتساب المهارات الحياتية اللازمة. الرياضيات مهارة أساسية في الحياة اليومية1%$s سوف تقدم دروسا جديدة في العلوم وغيرها من المواد قريبا!</p> + <p>أجل, %s سوف تقدم دروسا جديدة في العلوم وفي مواد أخرى قريبا. الرجاء زيارتنا مجددا لمعرفة كل ماهو جديد!</p> + <p>إذا لم يتم تحميل مشغل الاستكشاف</p><p><br></p><p>تحقق لمعرفة ما إذا كان التطبيق محدثًا أم لا:</p><p> <ul> <li> انتقل إلى متجر Play وتأكد من تحديث التطبيق إلى أحدث إصدار </li></ul><p><br></p><p>تحقق من اتصالك بالإنترنت:</p><ul><li> إذا كان اتصالك بالإنترنت بطيئًا ، فحاول إعادة الاتصال بشبكة Wi-Fi أو الاتصال بشبكة أخرى. </li></ul><p>اطلب من المشرف التحقق من أجهزتهم واتصال الإنترنت:</p><ul><li> اطلب من المشرف استكشاف الأخطاء وإصلاحها باستخدام الخطوات المذكورة أعلاه </li></ul><p>أخبرنا إذا كنت لا تزال تواجه مشكلات في التحميل:</p><ul><li> أبلغ عن مشكلة عن طريق الاتصال بنا على admin@oppia.org. </li></ul> + <p>إذا لم يتم تشغيل الصوت الخاص بك</p><p><br></p><p>تحقق لمعرفة ما إذا كان التطبيق محدثًا أم لا:</p><ul><li>انتقل إلى متجر Play وتأكد من تحديث التطبيق إلى أحدث إصدار</li></ul><p><br></p><p>تحقق من اتصالك بالإنترنت:</p><ul><li>إذا كان اتصالك بالإنترنت بطيئًا ، فحاول إعادة الاتصال بشبكة Wi-Fi أو الاتصال بشبكة أخرى. قد يتسبب الإنترنت البطيء في تحميل الصوت بشكل غير منتظم ، مما يجعل من الصعب تشغيله.</li></ul><p><br></p><p>اطلب من المسؤول التحقق من أجهزتهم واتصال الإنترنت:</p><ul><li>اطلب من المسؤول استكشاف الأخطاء وإصلاحها باستخدام الخطوات المذكورة أعلاه</li></ul><p><br></p><p>أخبرنا إذا كنت لا تزال تواجه مشكلات في التحميل:</p><ul><li>أبلغ عن مشكلة عن طريق الاتصال بنا على admin@oppia.org.</li></ul> + <p>بمجرد حذف ملف التعريف:</p><ol><li>لا يمكن استعادة ملف التعريف.</li><li>سيتم حذف معلومات الملف الشخصي مثل الاسم والصور والتقدم بشكل دائم.</li></ol><p>لحذف ملف تعريف (باستثناء<u>المسؤول</u>):</p><ol><li> من الصفحة الرئيسية للمسؤول ، اضغط على زر القائمة أعلى اليسار.</li><li> اضغط على<strong>ضوابط المسؤول</strong>. </li><li> اضغط على<strong>تحرير ملفات التعريف</strong>.</li><li> اضغط على الملف الشخصي الذي ترغب في حذفه.</li><li> في الجزء السفلي من الشاشة ، انقر فوق<strong>حذف الملف الشخصي</strong>. </li><li> اضغط<strong>حذف</strong>لتأكيد الحذف. </li></ol><p><br></p><p> ملاحظة:<u>المسؤول</u>فقط هو القادر على إدارة الملفات الشخصية.</p> + <p><ol><li>افتح تطبيق Google Play Store</li><li>ابحث عن %s التطبيق</li><li>اضغط على تحديث</li></ol></p> + <p><ol><li>اضغط على تطبيق الإعدادات في هاتفك</li><li>اضغط على تحديثات النظام</li><li>اضغط على تحديثات النظام واتبع التعليمات لتحديث نظام تشغيل Android الخاص بك</li></ol></p> <p> إذا لم تتمكن من العثور على سؤالك أو كنت ترغب في الإبلاغ عن خطأ ، فاتصل بنا على admin@oppia.org. </p> نشاط اختبار جزء تحرير ملف التعريف يتحكم المسؤول في نشاط اختبار التجزئة diff --git a/app/src/main/res/values-pcm-rNG/strings.xml b/app/src/main/res/values-pcm-rNG/strings.xml index 5e98c6d7d17..36fdceba18c 100644 --- a/app/src/main/res/values-pcm-rNG/strings.xml +++ b/app/src/main/res/values-pcm-rNG/strings.xml @@ -463,11 +463,11 @@ Beta Notice Hello! Your app don dey update to di Beta version. If you experience any problems while you dey use di app, or get any questions, abeg contact us at android-feedback@oppia.org. No show dis message again - OK + OK General Availability Notice Hello! Your app don dey update to di General Availability version. If you experience any problems while you dey use di app, or get any questions, abeg contact us at android-feedback@oppia.org. No show dis message again - OK + OK to Enter a ratio in di form x:y. Tap here to put text. @@ -514,24 +514,19 @@ I see bug for di app. How I fit report am? Why only math lessons dey available? Una go dey create more lessons? - Why di Exploration player no dey load? + Why di Exploration player no dey load? Why my audio no dey play? How I go delete a profile? How I fit update di app? How I fit update my Android OS? I no dey find my question here. What now? <p>%1$s <i>\"O-pee-yah\"</i> (Finnish) - \"to learn\"</p><p><br></p><p>%1$s\'s mission na to help anyone learn anything dey want in an effective and enjoyable way.</p><p><br></p><p>By creating a set of free, high-quality, demonstrably effective lessons with di help of educators from around di world, %1$s dey aim to provide students with quality education — regardless of where dem dey or di traditional resources wey dem get access to.</p><p><br></p><p>As a student, you fit start your learning adventure by browsing di topics listed on di Home Page!</p> - <p>An Administrator na di main user wey dey manage profiles and settings for every profile on top their account. They fit be your parent, teacher, or guardian wey don create dis profile for you. </p><p><br></p><p>Administrators get di ability to manage profiles, assign PINs, and change other settings under their account. Depending on your profile, Administrator permissions fit dey required for some features such as changing your PIN, and more. </p><p><br></p><p>To see who your Administrator be, go di Profile Chooser. Di first profile fot di list and get \"Administrator\" written under their name na di Administrator. </p> - <p>If na your first time creating a profile and not have a PIN:<ol><li>From di Profile Chooser, tap on <strong>Set up Multiple Profiles</strong>.</li><li>Create a PIN and <strong>Save</strong>.</li><li>Fill in all boxes for di profile.<ol><li>(Optional) Upload a photo.</li><li>Enter a name.</li><li>(Optional) Assign a 3-digit PIN.</li></ol></li><li>Tap <strong>Create</strong>. Dis profile go add to your Profile Chooser!</li></ol></p><p> If you don create a profile before and you get a PIN:<ol><li>From di Profile Chooser, tap on <strong>Add Profile</strong>. </li><li>Enter your PIN and tap <strong>Submit</strong>. </li><li>Fill in all boxes for di profile.<ol><li> (Optional) Upload a photo. </li><li> Enter a name. </li><li> (Optional) Assign a 3-digit PIN. </li></ol></li><li>Tap <strong>Create</strong>. Dis profile go add to your Profile Chooser!</li></ol></p><br><p>Note: Only di <u>Administrator</u> go dey able to manage profiles.</p> - <p>The %s app dey support English, Brazilian Portuguese, Arabic, Swahili, and Nigerian Pidgin. Select one of these languages for di menu, under Options. To ask for the app for your language, abeg contact us for admin@oppia.org.</p> - <p>
  1. From your %s app home screen, tap the menu for di top left corner.
  2. Tap Share feedback.
  3. Follow di instructions to report di bug or share feedback.
</p>
-

%1$s mission na to help learners gain necessary life skills. Math na essential skill for everyday life. %1$s go dey offer new lessons on science and other subjects very soon!

-

Yes, %s go dey offer new lessons on science and other subjects very soon. Abeg check back for updates!

- <p>If di Exploration Player no dey load</p><p><br></p><p>Check to see if di app dey up to date:</p><p><ul><li> Go to di Play Store and make sure sey di app dey updated to di latest version </li></ul><p><br></p><p>Check your internet connection:</p><ul><li> If your internet connection dey slow, try re-connecting to your Wi-Fi network or connecting to a different network. </li></ul><p>Ask di Administrator to check their device and internet connection:</p><ul><li> Get di Administrator to troubleshoot using di steps above </li></ul><p>Let us know if you still dey get issues with loading:</p><ul><li> Report a problem by contacting us at admin@oppia.org. </li></ul> - <p>If your audio no dey play</p><p><br></p><p>Check to see if di app dey up to date:</p><ul><li> Go to di Play Store and make sure sey di app dey updated to di latest version </li></ul><p><br></p><p>Check your internet connection:</p><ul><li> If your internet connection dey slow, try re-connecting to your Wi-Fi network or connecting to a different network. Slow internet fit cause di audio to load irregularly, and go make am difficult to play. </li></ul><p><br></p><p>Ask di Administrator to check their device and internet connection:</p><ul><li> Get di Administrator to troubleshoot using di steps for up</li></ul><p><br></p><p>Let us know if you still dey get issues with loading:</p><ul><li> Report a problem by contacting us at admin@oppia.org. </li></ul> - <p>Once profile don delete:</p><ol><li>Di profile no fit dey recovered. </li><li> Profile information such as name, photos, and progress go permanently delete. </li></ol><p>To delete a profile (excluding the <u>Administrator\'s</u>):</p><ol><li> From di Administrator\'s Home Page, tap on di menu button on di top left. </li><li>Tap on <strong>Administrator Controls</strong>. </li><li>Tap on <strong>Edit Profiles</strong>. </li><li>Tap on di Profile wey you wan delete. </li><li>For di bottom of di screen, tap <strong>Profile Deletion</strong>. </li><li>Tap <strong>Delete</strong> to confirm deletion. </li></ol><p><br></p><p>Note: Only di <u>Administrator</u> go dey able to manage profiles.</p> -

  1. Open di Google Play Store app.
  2. Search for di %s app.
  3. Tap Update.

-
  1. Tap your phone\'s Settings app.
  2. Tap System updates.
  3. Tap System updates and follow di instructions to update your Android operating system.
+ <p>An Administrator na di main user wey dey manage profiles and settings for every profile on top their account. They fit be your parent, teacher, or guardian wey don create dis profile for you. </p><p><br></p><p>Administrators get di ability to manage profiles, assign PINs, and change other settings under their account. Depending on your profile, Administrator permissions fit dey required for some features such as changing your PIN, and more. </p><p><br></p><p>To see who your Administrator be, go di Profile Chooser. Di first profile fot di list and get \"Administrator\" written under their name na di Administrator. </p> + <p>If na your first time creating a profile and not have a PIN:<ol><li>From di Profile Chooser, tap on <strong>Set up Multiple Profiles</strong>.</li><li>Create a PIN and <strong>Save</strong>.</li><li>Fill in all boxes for di profile.<ol><li>(Optional) Upload a photo.</li><li>Enter a name.</li><li>(Optional) Assign a 3-digit PIN.</li></ol></li><li>Tap <strong>Create</strong>. Dis profile go add to your Profile Chooser!</li></ol></p><p> If you don create a profile before and you get a PIN:<ol><li>From di Profile Chooser, tap on <strong>Add Profile</strong>. </li><li>Enter your PIN and tap <strong>Submit</strong>. </li><li>Fill in all boxes for di profile.<ol><li> (Optional) Upload a photo. </li><li> Enter a name. </li><li> (Optional) Assign a 3-digit PIN. </li></ol></li><li>Tap <strong>Create</strong>. Dis profile go add to your Profile Chooser!</li></ol></p><br><p>Note: Only di <u>Administrator</u> go dey able to manage profiles.</p> + <p>The %s app dey support English, Brazilian Portuguese, Arabic, Swahili, and Nigerian Pidgin. Select one of these languages for di menu, under Options. To ask for the app for your language, abeg contact us for .</p> + <p>If di Exploration Player no dey load</p><p><br></p><p>Check to see if di app dey up to date:</p><p><ul><li> Go to di Play Store and make sure sey di app dey updated to di latest version </li></ul><p><br></p><p>Check your internet connection:</p><ul><li> If your internet connection dey slow, try re-connecting to your Wi-Fi network or connecting to a different network. </li></ul><p>Ask di Administrator to check their device and internet connection:</p><ul><li> Get di Administrator to troubleshoot using di steps above </li></ul><p>Let us know if you still dey get issues with loading:</p><ul><li> Report a problem by contacting us at admin@oppia.org. </li></ul> + <p>If your audio no dey play</p><p><br></p><p>Check to see if di app dey up to date:</p><ul><li> Go to di Play Store and make sure sey di app dey updated to di latest version </li></ul><p><br></p><p>Check your internet connection:</p><ul><li> If your internet connection dey slow, try re-connecting to your Wi-Fi network or connecting to a different network. Slow internet fit cause di audio to load irregularly, and go make am difficult to play. </li></ul><p><br></p><p>Ask di Administrator to check their device and internet connection:</p><ul><li> Get di Administrator to troubleshoot using di steps for up</li></ul><p><br></p><p>Let us know if you still dey get issues with loading:</p><ul><li> Report a problem by contacting us at admin@oppia.org. </li></ul> + <p>Once profile don delete:</p><ol><li>Di profile no fit dey recovered. </li><li> Profile information such as name, photos, and progress go permanently delete. </li></ol><p>To delete a profile (excluding the <u>Administrator\'s</u>):</p><ol><li> From di Administrator\'s Home Page, tap on di menu button on di top left. </li><li>Tap on <strong>Administrator Controls</strong>. </li><li>Tap on <strong>Edit Profiles</strong>. </li><li>Tap on di Profile wey you wan delete. </li><li>For di bottom of di screen, tap <strong>Profile Deletion</strong>. </li><li>Tap <strong>Delete</strong> to confirm deletion. </li></ol><p><br></p><p>Note: Only di <u>Administrator</u> go dey able to manage profiles.</p> <p>If you no fit find your question or you go like to report a bug, contact us for admin@oppia.org.</p> Profile Edit Fragment Test Activity Administrator Controls Fragment Test Activity diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9711a238ba1..a514d6dc65e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,13 +1,17 @@ @@ -16,6 +20,7 @@ Meus Downloads Ajuda Reprodutor de Exploração + Explorar Ajuda Fechar Trocar Perfil @@ -26,18 +31,20 @@ Tocar áudio Pausar áudio %s o áudio não está disponível. - OK + Ok Cancelar Idioma de Áudio Atualmente Offline Certifique-se de que o Wi-Fi ou os dados móveis estejam ativados e tente novamente. - OK - OK + Ok + Ok Cancelar Atualmente em Dados Móveis O streaming de áudio pode usar muitos dados móveis. Não mostre esta mensagem novamente Cartão de Conceito + Cartão Conceitual 1 + Cartão Conceitual 2 Cartão de Revisão Pretende ir para a página do tópico? Seu progresso não será salvo. @@ -71,13 +78,16 @@ Voltar ao cartão anterior Avançar para o próximo cartão Enviar + Enviar Repetir Retornar ao Tópico Respostas Anteriores (%s) Cliques em %s + Selecione uma imagem para continuar. Aprender Novamente Veja Mais Veja Menos + Esta é uma visualização de texto de exemplo FAQs Perguntas em Destaque Perguntas Frequentes @@ -106,8 +116,11 @@ Digite um número. Escreva números com unidades aqui. Digite uma expressão aqui, usando apenas números. + Insira uma expressão numérica para continuar. Digite uma expressão aqui. + Insira uma expressão algébrica para continuar. Digite uma equação aqui. + Insira uma equação para continuar. Por favor, remova os espaços entre os números em sua resposta. Feche ou remova os parênteses. Por favor, remova os parênteses ao redor da resposta inteira: \'%s\'. @@ -169,14 +182,19 @@ Insira uma fração válida (por exemplo, 5/3 ou 1 2/3) Por favor, não coloque 0 no denominador Nenhum dos números da fração deve ter mais de 7 dígitos. + Insira uma fração para continuar. Comece sua resposta com um número (por exemplo, \"0\" em 0,5) Por favor, insira um número válido. A resposta pode conter no máximo 15 dígitos (0–9) ou símbolos (. ou -). - Escreva uma proporção que consista em dígitos separados por dois pontos (por exemplo, 1:2 ou 1:2:3). - Insira uma proporção válida (por exemplo, 1:2 ou 1:2:3). - Sua resposta tem dois dois-pontos (:) do lado um do outro. - O número de termos não é igual ao exigido. - Proporções não podem ter 0 como elemento. + Digite um número para continuar. + Escreva uma proporção que consista em dígitos separados por dois pontos (por exemplo, 1:2 ou 1:2:3). + Insira uma proporção válida (por exemplo, 1:2 ou 1:2:3). + Sua resposta tem dois pontos (:) próximos um do outro. + O número de termos não é igual aos termos exigidos. + As proporções não podem ter 0 como elemento. + Insira uma proporção para continuar. + Digite o texto para continuar. + Escolha uma resposta para continuar. Tamanho desconhecido %s Bytes %s KB @@ -307,6 +325,8 @@ Início A partir de agora, você pode ver as lições recomendadas para você aqui. Selecione um Tópico para começar + Início + Salas de aula Perfis Editar Perfil Criado em %s @@ -330,7 +350,7 @@ Escolha da Biblioteca Renomear Perfil Novo Nome - salvar + Salvar Redefinir PIN Insira um novo PIN para o usuário usar ao acessar seu perfil. PIN de 3 Dígitos @@ -343,6 +363,7 @@ Requerido Botão de Voltar Próximo + Análise de estudo do aluno Geral Editar conta Gerenciamento de Perfil @@ -363,12 +384,12 @@ A última atualização foi instalada em %s. Use o número da versão acima para enviar feedback sobre erros. Versão do Aplicativo Idioma do Aplicativo - Idioma Padrão de Áudio + Idioma de áudio preferido Tamanho do Texto de Leitura Tamanho do Texto de Leitura O texto da história ficará assim. A - Áudio Padrão + Idioma de áudio preferido Idioma do Aplicativo Tamanho do Texto de Leitura Pequeno @@ -391,7 +412,7 @@ Ótimo Vamos começar. Sim - Não… + Não… Escolha um\ntópico diferente. Você está interessado em:\n%s? Nova dica disponível @@ -417,6 +438,7 @@ Voltar para a lição Explicação: Se dois itens forem iguais, junte-os. + Organize as caixas para continuar. Link para o item %s Desvincular itens em %s Mover o item para baixo para %s @@ -427,9 +449,20 @@ topic_revision_recyclerview_tag ongoing_recycler_view_tag Por favor, selecione todas as alternativas corretas. - Versão do aplicativo não suportada + Versão do aplicativo não suportada Esta versão do aplicativo não é mais suportada. Atualize-a na Play Store. Fechar aplicativo + Atualização do aplicativo necessária + Uma nova versão de %s já está disponível. A nova versão é mais segura e melhora sua experiência de aprendizado.\n\nEsta versão não é mais suportada. Para continuar usando o aplicativo, atualize para a versão mais recente. + Atualizar + Fechar App + Nova atualização disponível + Uma nova versão de %s já está disponível. Recomendamos que você atualize o aplicativo para corrigir bugs e melhorar a experiência de aprendizado. + Dispensar + Atualizar + Atualize seu sistema operacional Android + Recomendamos atualizar seu sistema operacional Android para aproveitar os novos recursos e lições do %s.\n\nVisite o aplicativo Configurações do seu telefone para atualizar seu sistema operacional. + Dispensar Versão do desenvolvedor Alfa Beta @@ -441,9 +474,11 @@ Olá! Seu aplicativo está sendo atualizado para a versão de disponibilidade geral. Se você tiver problemas ao usar o aplicativo ou tiver dúvidas, entre em contato conosco em android-feedback@oppia.org. Não exibir esta mensagem novamente OK - para - Insira uma razão no formato x:y. + para + Insira uma proporção no formato x:y. Clique aqui para inserir texto. + Escreva o dígito aqui. + Escreva aqui. Menor tamanho de texto Maior tamanho de texto Em Breve @@ -481,16 +516,28 @@ O que é %s? Quem é um administrador? Como posso criar um novo perfil? + Como faço para obter o aplicativo no meu idioma? + Achei um bug. Como posso reportar? + Por que só há aulas de matemática? + Vocês vão criar mais lições? Por que a exploração não está carregando? Por que meu áudio não está tocando? Como posso deletar um perfil? + Como eu atualizo o aplicativo? + Como eu atualizo meu sistema operacional Android? Não consigo encontrar minha pergunta aqui. E agora? <p>%1$s <i>\"O-pee-yah\"</i> (Finnish) - \"aprender\"</p><p><br></p><p>%1$s tem a missão de ajudar qualquer pessoa a aprender o que quiser de uma forma eficaz e agradável.</p><p><br></p><p>Ao criar um conjunto de aulas gratuitas, de alta qualidade e comprovadamente eficazes com a ajuda de educadores de todo o mundo, %1$s visa proporcionar aos alunos uma educação de qualidade - independentemente de onde estejam ou a quais recursos tradicionais tenham acesso.</p><p><br></p><p>Como estudante, você pode começar sua aventura de aprendizado navegando pelos tópicos listados na página inicial!</p> - <p>Um administrador é o usuário principal que gerencia perfis e configurações para cada perfil em sua conta. Provavelmente, eles são seus pais, professores ou responsáveis ​​que criaram este perfil para você.</p><p><br></p><p>Os administradores podem gerenciar perfis, atribuir PINs e alterar outras configurações em suas contas. Dependendo do seu perfil, as permissões de administrador podem ser necessárias para determinados recursos, como download de tópicos, alteração do PIN e mais. </p><p><br></p><p>Para ver quem é o seu administrador, vá para o Seletor de perfil. O primeiro perfil listado e com \"Administrador\" escrito em seu nome é o Administrador. </p> - <p>Se é a sua primeira vez criando um perfil e você não tem um PIN:<ol> <li> No Seletor de Perfil, toque em <strong>Configurar Múltiplos Perfis</strong>. </li> <li> Crie um PIN e <strong>Salvar</strong>. </li> <li> Preencha todos os campos do perfil. <ol> <li> (Opcional) Carregue uma foto. </li> <li> Insira um nome. </li> <li> (Opcional) Atribua um PIN de 3 dígitos. </li></ol></li><li> 4. Toque em <strong>Criar</strong>. Este perfil está adicionado ao seu Seletor de Perfil! </li></ol></p><p> Se você já criou um perfil antes e tem um PIN: <ol> <li>No Seletor de Perfil, toque em <strong>Adicionar Perfil</strong>. </li> <li> 2. Digite seu PIN e toque em <strong>Enviar</strong>. </li> <li> Preencha todos os campos do perfil. <ol> <li> (Opcional) Carregue uma foto. </li> <li> Insira um nome. </li> <li> (Opcional) Atribua um PIN de 3 dígitos. </li> </ol> </li> <li> 4. Toque em <strong>Criar</strong>. Este perfil está adicionado ao seu Seletor de Perfil! </li></ol></p><br><p> Nota: Apenas o <u>Administrador</u> pode gerenciar perfis.</p> - <p>Se a exploração não estiver carregando</p><p><br></p><p>Verifique se o aplicativo está atualizado:</p><p> <ul> <li> Acesse a Play Store e certifique-se de que o aplicativo esteja atualizado com a versão mais recente </li></ul><p><br></p><p>Verifique sua conexão com a internet:</p><ul><li> Se sua conexão com a Internet estiver lenta, tente se reconectar à rede Wi-Fi ou conectar-se a uma rede diferente. </li></ul><p>Peça ao administrador para verificar o dispositivo e a conexão com a Internet:</p><ul><li> Peça ao administrador para solucionar o problema usando as etapas acima </li></ul><p>Informe-nos se você ainda tiver problemas com o carregamento::</p><ul><li> Relate um problema entrando em contato conosco em admin@oppia.org. </li> </ul> - <p>Se o seu áudio não estiver tocando</p><p><br></p><p>Verifique se o aplicativo está atualizado:</p><ul><li> Acesse a Play Store e certifique-se de que o aplicativo esteja atualizado com a versão mais recente </li></ul><p><br></p><p>Verifique sua conexão com a internet:</p><ul><li> Se sua conexão com a Internet estiver lenta, tente se reconectar à rede Wi-Fi ou conectar-se a uma rede diferente. A Internet lenta pode fazer com que o áudio carregue irregularmente, dificultando a reprodução. </li></ul><p><br></p><p>Peça ao administrador para verificar o dispositivo e a conexão com a Internet:</p><ul><li> Peça ao administrador para solucionar o problema usando as etapas acima</li></ul><p><br></p><p>Informe-nos se você ainda tiver problemas com o carregamento:</p><ul><li> Relate um problema entrando em contato conosco em admin@oppia.org. </li></ul> - <p>Depois que um perfil é deletado:</p> <ol><li>O perfil não pode ser recuperado. </li> <li> As informações do perfil, como nome, fotos e progresso, serão excluídas permanentemente. </li></ol><p>Para deletar um perfil(excluindo o do <u>Administrador</u>):</p> <ol><li>Na página inicial do administrador, toque no botão de menu no canto superior esquerdo.</li> <li>Toque em <strong>Controles do Administrador</strong>.</li> <li>3. Toque em <strong>Editar Perfis</strong>.</li> <li>4. Toque no perfil que deseja excluir.</li> <li>5. Na parte inferior da tela, toque em <strong>Exclusão de Perfil</strong>.</li> <li>6. Toque em <strong>Deletar</strong> para confirmar a exclusão.</li></ol><p><br></p><p>Nota: Apenas o <u>Administrador</u> pode gerenciar perfis.</p> + <p>Um administrador é o usuário principal que gerencia perfis e configurações de cada perfil da conta. Provavelmente foram seus pais, professores ou responsáveis ​​que criaram este perfil para você.</p><p><br></p><p>Os administradores podem gerenciar perfis, atribuir PINs e alterar outras configurações. sob sua conta. Dependendo do seu perfil, podem ser necessárias permissões de administrador para determinados recursos, como alteração do PIN e muito mais.</p><p><br></p><p>Para ver quem é o seu administrador, acesse o Perfil. Seletor. O primeiro perfil listado e com \"Administrador\" escrito em seu nome é o Administrador.</p> + <p>Se for a primeira vez que você cria um perfil e você não tem um PIN:<ol><li>No Seletor de perfil, toque em <strong>Configurar vários perfis</strong>.</li>< li>Crie um PIN e <strong>Salve</strong>.</li><li>Preencha todos os campos do perfil.<ol><li>(Opcional) Faça upload de uma foto.</li><li> Digite um nome.</li><li>(Opcional) Atribua um PIN de três dígitos.</li></ol></li><li>Toque em <strong>Criar</strong>. Este perfil será adicionado ao seu Seletor de perfil!</li></ol></p><p>Se você já criou um perfil e possui um PIN:<ol><li>No Seletor de perfil, toque em < strong>Adicionar perfil</strong>.</li><li>Insira seu PIN e toque em <strong>Enviar</strong>.</li><li>Preencha todos os campos do perfil.<ol><li >(Opcional) Faça upload de uma foto.</li><li>Digite um nome.</li><li>(Opcional) Atribua um PIN de três dígitos.</li></ol></li><li >Toque em <strong>Criar</strong>. Este perfil é adicionado ao seu Seletor de perfil!</li></ol></p><p><br></p><p>Observação: somente o <u>Administrador</u> pode gerenciar perfis.</p> + <p>O aplicativo %s atualmente oferece suporte a inglês, português do Brasil, árabe, suaíli e pidgin nigeriano. Escolha um desses idiomas no menu, em Opções. Para solicitar o aplicativo em seu idioma, entre em contato conosco pelo e-mail <strong>admin@oppia.org</strong>.</p> + <p><ol><li>Na tela inicial do aplicativo %s, toque no menu no canto superior esquerdo.</li><li>Toque em <strong>Compartilhar feedback</strong>.</li><li >Siga as instruções para relatar o bug ou compartilhar comentários.</li></ol></p> + <p>A missão do %1$s é ajudar os alunos a adquirir as habilidades necessárias para a vida. A matemática é uma habilidade essencial na vida cotidiana. %1$s oferecerá novas aulas sobre ciências e outros assuntos em breve!</p> + <p>Sim, %s oferecerá novas aulas sobre ciências e outros assuntos em breve. Por favor, volte para atualizações!</p> + <p>Se o Exploration Player não estiver carregando</p><p><br></p><p>Verifique se o aplicativo está atualizado:</p><ul><li>Acesse na Play Store e verifique se o aplicativo está atualizado para a versão mais recente</li></ul><p><br></p><p>Verifique sua conexão com a Internet:</p><ul><li> Se sua conexão com a Internet estiver lenta, tente reconectar-se à rede Wi-Fi ou conectar-se a uma rede diferente.</li></ul><p>Peça ao administrador para verificar o dispositivo e a conexão com a Internet:</p> <ul><li>Peça ao administrador para solucionar o problema usando as etapas acima</li></ul><p><br></p><p>Informe-nos se você ainda tiver problemas com o carregamento:</p ><ul><li>Informe um problema entrando em contato conosco pelo e-mail admin@oppia.org.</li></ul> + <p>Se o áudio não estiver sendo reproduzido</p><p><br></p><p>Verifique se o aplicativo está atualizado:</p><ul><li>Vá para o Play Store e verifique se o aplicativo está atualizado para a versão mais recente</li></ul><p><br></p><p>Verifique sua conexão com a Internet:</p><ul><li>Se sua conexão com a Internet está lenta, tente reconectar-se à sua rede Wi-Fi ou conectar-se a uma rede diferente. A Internet lenta pode fazer com que o áudio carregue irregularmente, dificultando a reprodução.</li></ul><p><br></p><p>Peça ao administrador para verificar o dispositivo e a conexão com a Internet:</ p><ul><li>Peça ao administrador para solucionar o problema usando as etapas acima</li></ul><p><br></p><p>Informe-nos se você ainda tiver problemas com o carregamento:< /p><ul><li>Informe um problema entrando em contato conosco pelo e-mail admin@oppia.org.</li></ul> + <p>Depois que um perfil for excluído:</p><ol><li>O perfil não poderá ser recuperado.</li><li>As informações do perfil, como nome, fotos e progresso, serão excluídas permanentemente.</li </ol><p>Para excluir um perfil (excluindo o <u>Administrador</u>):</p><ol><li>Na página inicial do administrador, toque no botão de menu no canto superior esquerdo .</li><li>Toque em <strong>Controles do administrador</strong>.</li><li>Toque em <strong>Editar perfis</strong>.</li><li>Toque no perfil você deseja excluir.</li><li>Na parte inferior da tela, toque em <strong>Exclusão de perfil</strong>.</li><li>Toque em <strong>Excluir</strong> para confirmar a exclusão .</li></ol><p><br></p><p>Nota: Somente o <u>Administrador</u> pode gerenciar perfis.</p> + <p><ol><li>Abra o aplicativo Google Play Store.</li><li>Procure o aplicativo %s.</li><li>Toque em Atualizar.</li></ol></ p> + <p><ol><li>Toque no aplicativo Configurações do seu telefone.</li><li>Toque em Atualizações do sistema.</li><li>Toque em Atualizações do sistema e siga as instruções para atualizar seu sistema operacional Android.</li </ol></p> <p>Se você não consegue encontrar sua pergunta ou gostaria de relatar um problema, entre em contato conosco em admin@oppia.org.</p> Atividade de Teste de Fragmento de Edição de Perfil Controle Administrativo da Atividade de Teste de Fragmento @@ -532,4 +579,52 @@ Em uma escala de 0 a 10, qual é a probabilidade de você recomendar %s a um amigo ou colega? O subtópico anterior é %s O próximo subtópico é %s + Informações do aplicativo + Seta de sobreposição do cursor + Fechar cursor + Botão de navegação do estado anterior + Ícone de opções do desenvolvedor + Ícone de controles do administrador + Menu de opções + Botão Anterior + Próximo botão + Ícone de idioma + Ícone de configuração + Visualização da imagem da foto do perfil + Ícone de cadeado + Estado de download + Conteúdo HTML + Bem vindo a %s! + Aprenda matemática gratuitamente, a qualquer hora! + Feito para estudantes de 7 a 14 anos + Selecione um idioma para começar + Você pode alterar sua seleção de idioma a qualquer momento nas configurações do aplicativo + Vamos lá! + Selecione o tipo de perfil + Conte-nos mais sobre você! + Sou estudante e quero aprender coisas novas! + Sou pai, professor ou responsável por um aluno. + PASSO 2 DE 5 + Criar perfil + Como devemos chamá-lo? + Apelido + Toque aqui para adicionar uma foto + Clique na caixa acima para digitar seu apelido. + Editar foto do perfil + Foto do perfil atual + PASSO 3 DE 5 + Bem-vindo + Bem-vindo, %s! + Aprenda matemática através de aulas divertidas baseadas em histórias. + Experimente questões práticas para testar seu conhecimento. + Obtenha feedback para melhorar usando as correções de %s. + PASSO 4 DE 5 + Lontra fofa usando óculos. + Lontra fofa com livros. + Mamãe e bebê lontra. + Voltar + Continuar + Em %s, você pode ouvir as lições! + Selecione a lingua do áudio para ouvir às lições + Passo 5 de 5 diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml index 17d2fd54cec..ddb536c4e3e 100644 --- a/app/src/main/res/values-sw/strings.xml +++ b/app/src/main/res/values-sw/strings.xml @@ -1,6 +1,7 @@ @@ -381,6 +382,8 @@ Toleo la programu lisilotumika Toleo hili la programu halitumiki tena. Tafadhali isasishe kupitia hifadhi ya michezo. Funga programu + Sawa + Sawa kwa Weka uwiano katika fomu x:y. Maandishi madogo zaidi @@ -425,16 +428,10 @@ Jinsi gani naweza kusasisha mfumo wangu wa Android? Sijapata swali langu hapa. Nini sasa? <p>%1$s <i>\"O-pee-yah\"</i> (Kifini) - \"kujifunza\"</p><p><br></p><p>%1$s\'s dhamira ni kumsaidia mtu yeyote kujifunza chochote anachotaka kwa njia bora na ya kufurahisha.</p><p><br></p><p>Kwa kuunda seti ya masomo yasiyolipishwa, ya ubora wa juu, na yenye matokeo kwa usaidizi. ya waelimishaji kutoka duniani kote, %1$s inalenga kuwapa wanafunzi elimu bora — bila kujali walipo au ni nyenzo gani za jadi wanazoweza kufikia.</p><p><br></p><p> Kama mwanafunzi, unaweza kuanza safari yako ya kujifunza kwa kuvinjari mada zilizoorodheshwa kwenye Ukurasa wa Mwanzo!</p> - <p>Msimamizi ndiye mtumiaji mkuu anayedhibiti wasifu na mipangilio ya kila wasifu kwenye akaunti yake.Uwezekano mkubwa ni mzazi, mwalimu au mlezi wako aliyekuundia wasifu huu. </p><p><br></p><p>Wasimamizi wana uwezo wa kudhibiti wasifu, kugawa Nambari ya Siri, na kubadilisha mipangilio mingine chini ya akaunti yao. Kulingana na wasifu wako, ruhusa za Msimamizi zinaweza kuhitajika kwa vipengele fulani kama vile kupakua Mada, kubadilisha Nambari yako ya Siri na zaidi. </p><p><br></p><p>Ili kujua Msimamizi wako ni nani, nenda kwa Kichagua Wasifu. Wasifu wa kwanza ulioorodheshwa na una \"Msimamizi\" iliyoandikwa chini ya jina lake ni Msimamizi. </p> - <p>Ikiwa ni mara yako ya kwanza kuunda wasifu na huna Nambari ya Siri: <ol> <li> Kutoka kwa Kichagua Wasifu, gusa <strong>Weka Wasifu Nyingi</strong>. </li> <li> Unda Nambari ya Siri na <strong>Hifadhi</strong>. </li> <li> Jaza sehemu zote za wasifu. <ol><li> (Si lazima) Pakia picha. </li> <li> Weka jina. </li> <li> (Si lazima) Weka Nambari ya Siri yenye tarakimu 3.</li></ol></li><li>Gusa <strong>Unda</strong>. Wasifu huu umeongezwa kwa Kichagua Wasifu wako! </li></ol></p><p> Ikiwa umeunda wasifu hapo awali na una Nambari ya Siri: <ol><li> Kutoka kwa Kichagua Wasifu, gusa <strong>Ongeza Wasifu</strong>. </li><li>Weka Nambari yako ya Siri na uguse <strong>Wasilisha</strong>. </li><li> Jaza sehemu zote za wasifu. <ol><li> (Si lazima) Pakia picha. </li> <li> Weka jina. </li> <li> (Si lazima) Weka Nambari ya Siri yenye tarakimu 3. </li></ol></li><li> Gusa <strong>Unda</strong>. Wasifu huu umeongezwa kwa Kichagua Wasifu wako! </li></ol></p><br><p> Kumbuka: <u>Msimamizi pekee</u> ndiye anayeweza kudhibiti wasifu.</p> -

Programu ya %s kwa sasa inasaidia Kiingereza, Kireno cha Brazil, Kiarabu, Kiswahili, na Kipidgin cha Nigeria. Chagua moja ya lugha hizi kwenye menyu, chini ya Chaguo. Ili kuomba programu kwa lugha yako, tafadhali wasiliana nasi kwa admin@oppia.org.

-

  1. Kutoka kwenye skrini kuu ya programu yako ya %s, bonyeza menyu kwenye kona ya juu kushoto.
  2. Bonyeza Shiriki Maoni.
  3. Fuata maagizo ya kuripoti mdudu au kushiriki maoni.

-

Malengo ya %1$s ni kusaidia wanafunzi kupata stadi muhimu za maisha. Hisabati ni stadi muhimu katika maisha ya kila siku. %1$s itatoa masomo mapya kuhusu sayansi na masomo mengine hivi karibuni!

-

Ndiyo, %s itatoa masomo mapya kuhusu sayansi na masomo mengine hivi karibuni. Tafadhali rudia kwa ajili ya habari mpya!

- <p>Ikiwa Kicheza Ugunduzi hakipakii</p><p><br></p><p>Angalia kama programu imesasishwa:</p><p> <ul> <li> Nenda kwenye Hifadhi ya Michezo na uhakikishe kuwa programu imesasishwa hadi toleo lake jipya zaidi </li> </ul> <p><br></p><p>Angalia muunganisho wako wa mtandao:</p><ul><li> Ikiwa muunganisho wako wa mtandao ni wa polepole, jaribu kuunganisha tena kwenye mtandao wako wa Wi-Fi au unganisha kwenye mtandao tofauti. </li></ul><p>Uliza Msimamizi aangalie kifaa chake na muunganisho wa mtandao:</p><ul><li> Pata Msimamizi kusuluhisha kwa kutumia hatua hapo juu </li></ul><p>Tujulishe ikiwa bado una matatizo ya upakiaji:</p><ul><li> Ripoti tatizo kwa kuwasiliana nasi kwa admin@oppia.org. </li></ul> - <p>Ikiwa sauti yako haichezi</p><p><br></p><p>Angalia ili kuona kama programu imesasishwa:</p><ul><li> Nenda kwenye Hifadhi ya Michezo na uhakikishe kuwa programu imesasishwa hadi toleo lake jipya zaidi </li></ul><p><br></p><p>Angalia muunganisho wako wa mtandao:</p><ul><li> Iwapo muunganisho wako wa mtandao ni wa polepole, jaribu kuunganisha tena kwenye mtandao wako wa Wi-Fi au unganisha kwenye mtandao tofauti. Mtandao wa polepole unaweza kusababisha sauti kupakia kwa njia isiyo ya kawaida, na kuifanya iwe vigumu kucheza. </li></ul><p><br></p><p>Uliza Msimamizi aangalie kifaa chake na muunganisho wa mtandao:</p><ul><li> Wasiliana na Msimamizi ili asuluhishe kwa kutumia hatua hapo juu</li></ul><p><br></p><p>Tujulishe ikiwa bado una matatizo ya upakiaji:</p><ul><li> Ripoti tatizo kwa kuwasiliana nasi kwa admin@oppia.org. </li></ul> - <p>Wasifu unapofutwa:</p><ol><li> Wasifu hauwezi kurejeshwa. </li><li> Taarifa ya wasifu kama vile jina, picha na maendeleo yatafutwa kabisa. </li></ol><p>Ili kufuta wasifu (bila kujumuisha <u>Msimamizi</u>):</p> <ol><li> Kutoka kwa Ukurasa wa Mwanzo wa Msimamizi, gusa kitufe cha menyu kilicho upande wa juu kushoto. </li><li> Gusa <strong>Vidhibiti vya Msimamizi</strong>. </li><li> Gusa <strong>Hariri Wasifu</strong>. </li><li> Gonga Wasifu ambao ungependa kufuta. </li><li> Katika sehemu ya chini ya skrini, gusa <strong>Ufutaji wa Wasifu</strong>. </li><li> Gusa <strong>Futa</strong> ili kuthibitisha kufuta.</li></ol><p><br></p><p>Kumbuka: <u>Msimamizi</u> pekee ndiye anayeweza kudhibiti wasifu.</p> -

  1. Fungua programu ya Duka la Google Play.
  2. Tafuta programu ya %s.
  3. Bonyeza Sasisha.

-

  1. Bonyeza programu ya Mipangilio kwenye simu yako.
  2. Bonyeza Sasisho la mfumo.
  3. Bonyeza Sasisho la mfumo na fuata maagizo ya kusasisha mfumo wako wa uendeshaji wa Android.

+ <p>Msimamizi ndiye mtumiaji mkuu anayedhibiti wasifu na mipangilio ya kila wasifu kwenye akaunti yake.Uwezekano mkubwa ni mzazi, mwalimu au mlezi wako aliyekuundia wasifu huu. </p><p><br></p><p>Wasimamizi wana uwezo wa kudhibiti wasifu, kugawa Nambari ya Siri, na kubadilisha mipangilio mingine chini ya akaunti yao. Kulingana na wasifu wako, ruhusa za Msimamizi zinaweza kuhitajika kwa vipengele fulani kama vile kupakua Mada, kubadilisha Nambari yako ya Siri na zaidi. </p><p><br></p><p>Ili kujua Msimamizi wako ni nani, nenda kwa Kichagua Wasifu. Wasifu wa kwanza ulioorodheshwa na una \"Msimamizi\" iliyoandikwa chini ya jina lake ni Msimamizi. </p> + <p>Ikiwa ni mara yako ya kwanza kuunda wasifu na huna Nambari ya Siri: <ol> <li> Kutoka kwa Kichagua Wasifu, gusa <strong>Weka Wasifu Nyingi</strong>. </li> <li> Unda Nambari ya Siri na <strong>Hifadhi</strong>. </li> <li> Jaza sehemu zote za wasifu. <ol><li> (Si lazima) Pakia picha. </li> <li> Weka jina. </li> <li> (Si lazima) Weka Nambari ya Siri yenye tarakimu 3.</li></ol></li><li>Gusa <strong>Unda</strong>. Wasifu huu umeongezwa kwa Kichagua Wasifu wako! </li></ol></p><p> Ikiwa umeunda wasifu hapo awali na una Nambari ya Siri: <ol><li> Kutoka kwa Kichagua Wasifu, gusa <strong>Ongeza Wasifu</strong>. </li><li>Weka Nambari yako ya Siri na uguse <strong>Wasilisha</strong>. </li><li> Jaza sehemu zote za wasifu. <ol><li> (Si lazima) Pakia picha. </li> <li> Weka jina. </li> <li> (Si lazima) Weka Nambari ya Siri yenye tarakimu 3. </li></ol></li><li> Gusa <strong>Unda</strong>. Wasifu huu umeongezwa kwa Kichagua Wasifu wako! </li></ol></p><br><p> Kumbuka: <u>Msimamizi pekee</u> ndiye anayeweza kudhibiti wasifu.</p> + <p>Ikiwa Kicheza Ugunduzi hakipakii</p><p><br></p><p>Angalia kama programu imesasishwa:</p><p> <ul> <li> Nenda kwenye Hifadhi ya Michezo na uhakikishe kuwa programu imesasishwa hadi toleo lake jipya zaidi </li> </ul> <p><br></p><p>Angalia muunganisho wako wa mtandao:</p><ul><li> Ikiwa muunganisho wako wa mtandao ni wa polepole, jaribu kuunganisha tena kwenye mtandao wako wa Wi-Fi au unganisha kwenye mtandao tofauti. </li></ul><p>Uliza Msimamizi aangalie kifaa chake na muunganisho wa mtandao:</p><ul><li> Pata Msimamizi kusuluhisha kwa kutumia hatua hapo juu </li></ul><p>Tujulishe ikiwa bado una matatizo ya upakiaji:</p><ul><li> Ripoti tatizo kwa kuwasiliana nasi kwa admin@oppia.org. </li></ul> + <p>Ikiwa sauti yako haichezi</p><p><br></p><p>Angalia ili kuona kama programu imesasishwa:</p><ul><li> Nenda kwenye Hifadhi ya Michezo na uhakikishe kuwa programu imesasishwa hadi toleo lake jipya zaidi </li></ul><p><br></p><p>Angalia muunganisho wako wa mtandao:</p><ul><li> Iwapo muunganisho wako wa mtandao ni wa polepole, jaribu kuunganisha tena kwenye mtandao wako wa Wi-Fi au unganisha kwenye mtandao tofauti. Mtandao wa polepole unaweza kusababisha sauti kupakia kwa njia isiyo ya kawaida, na kuifanya iwe vigumu kucheza. </li></ul><p><br></p><p>Uliza Msimamizi aangalie kifaa chake na muunganisho wa mtandao:</p><ul><li> Wasiliana na Msimamizi ili asuluhishe kwa kutumia hatua hapo juu</li></ul><p><br></p><p>Tujulishe ikiwa bado una matatizo ya upakiaji:</p><ul><li> Ripoti tatizo kwa kuwasiliana nasi kwa admin@oppia.org. </li></ul> + <p>Wasifu unapofutwa:</p><ol><li> Wasifu hauwezi kurejeshwa. </li><li> Taarifa ya wasifu kama vile jina, picha na maendeleo yatafutwa kabisa. </li></ol><p>Ili kufuta wasifu (bila kujumuisha <u>Msimamizi</u>):</p> <ol><li> Kutoka kwa Ukurasa wa Mwanzo wa Msimamizi, gusa kitufe cha menyu kilicho upande wa juu kushoto. </li><li> Gusa <strong>Vidhibiti vya Msimamizi</strong>. </li><li> Gusa <strong>Hariri Wasifu</strong>. </li><li> Gonga Wasifu ambao ungependa kufuta. </li><li> Katika sehemu ya chini ya skrini, gusa <strong>Ufutaji wa Wasifu</strong>. </li><li> Gusa <strong>Futa</strong> ili kuthibitisha kufuta.</li></ol><p><br></p><p>Kumbuka: <u>Msimamizi</u> pekee ndiye anayeweza kudhibiti wasifu.</p> <p>Ikiwa huwezi kupata swali lako au ungependa kuripoti hitilafu, wasiliana nasi kwa admin@oppia.org.</p>
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/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 61eaea05390..32fcd43fce6 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -74,7 +74,8 @@ diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt index 393311789b3..b0054de19ff 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/ColorBindingAdaptersTest.kt @@ -91,7 +91,7 @@ import org.robolectric.annotation.LooperMode import javax.inject.Inject import javax.inject.Singleton -/** Tests for [MarginBindingAdapters]. */ +/** Tests for [ColorBindingAdapters]. */ @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @Config( diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt new file mode 100644 index 00000000000..844f2e70327 --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/TextInputLayoutBindingAdaptersTest.kt @@ -0,0 +1,239 @@ +package org.oppia.android.app.databinding + +import android.app.Application +import android.content.Context +import android.widget.AutoCompleteTextView +import androidx.appcompat.app.AppCompatActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.textfield.TextInputLayout +import com.google.common.truth.Truth.assertThat +import dagger.Component +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentFactory +import org.oppia.android.app.activity.route.ActivityRouterModule +import org.oppia.android.app.application.ApplicationComponent +import org.oppia.android.app.application.ApplicationInjector +import org.oppia.android.app.application.ApplicationInjectorProvider +import org.oppia.android.app.application.ApplicationModule +import org.oppia.android.app.application.ApplicationStartupListenerModule +import org.oppia.android.app.application.testing.TestingBuildFlavorModule +import org.oppia.android.app.devoptions.DeveloperOptionsModule +import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule +import org.oppia.android.app.shim.ViewBindingShimModule +import org.oppia.android.app.testing.TextInputLayoutBindingAdaptersTestActivity +import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.data.backends.gae.NetworkConfigProdModule +import org.oppia.android.data.backends.gae.NetworkModule +import org.oppia.android.domain.classify.InteractionsModule +import org.oppia.android.domain.classify.rules.algebraicexpressioninput.AlgebraicExpressionInputModule +import org.oppia.android.domain.classify.rules.continueinteraction.ContinueModule +import org.oppia.android.domain.classify.rules.dragAndDropSortInput.DragDropSortInputModule +import org.oppia.android.domain.classify.rules.fractioninput.FractionInputModule +import org.oppia.android.domain.classify.rules.imageClickInput.ImageClickInputModule +import org.oppia.android.domain.classify.rules.itemselectioninput.ItemSelectionInputModule +import org.oppia.android.domain.classify.rules.mathequationinput.MathEquationInputModule +import org.oppia.android.domain.classify.rules.multiplechoiceinput.MultipleChoiceInputModule +import org.oppia.android.domain.classify.rules.numberwithunits.NumberWithUnitsRuleModule +import org.oppia.android.domain.classify.rules.numericexpressioninput.NumericExpressionInputModule +import org.oppia.android.domain.classify.rules.numericinput.NumericInputRuleModule +import org.oppia.android.domain.classify.rules.ratioinput.RatioInputModule +import org.oppia.android.domain.classify.rules.textinput.TextInputRuleModule +import org.oppia.android.domain.exploration.ExplorationProgressModule +import org.oppia.android.domain.exploration.ExplorationStorageModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionConfigModule +import org.oppia.android.domain.hintsandsolution.HintsAndSolutionProdModule +import org.oppia.android.domain.onboarding.ExpirationMetaDataRetrieverModule +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.domain.oppialogger.LoggingIdentifierModule +import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule +import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule +import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule +import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule +import org.oppia.android.domain.platformparameter.PlatformParameterModule +import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.domain.question.QuestionModule +import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.TestImageLoaderModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.espresso.EditTextInputAction +import org.oppia.android.testing.firebase.TestAuthenticationModule +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestCoroutineDispatchers +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.gcsresource.GcsResourceModule +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.EventLoggingConfigurationModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.logging.SyncStatusModule +import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule +import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule +import org.oppia.android.util.parser.image.ImageParsingModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [TextInputLayoutBindingAdapters]. */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + application = TextInputLayoutBindingAdaptersTest.TestApplication::class, + qualifiers = "port-xxhdpi" +) +class TextInputLayoutBindingAdaptersTest { + @Inject + lateinit var context: Context + + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Inject + lateinit var editTextInputAction: EditTextInputAction + + @Before + fun setUp() { + setUpTestApplicationComponent() + Intents.init() + testCoroutineDispatchers.registerIdlingResource() + } + + @After + fun tearDown() { + testCoroutineDispatchers.registerIdlingResource() + Intents.release() + } + + @Test + fun testBindingAdapters_setErrorMessage_setsMessageCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: TextInputLayout = activity.findViewById(R.id.test_text_input_view) + TextInputLayoutBindingAdapters.setErrorMessage(testView, "Some error message.") + assertThat(testView.error).isEqualTo("Some error message.") + } + } + } + + @Test + fun testBindingAdapters_setSelection_filterDisabled_setsSelectionCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: AutoCompleteTextView = activity.findViewById(R.id.test_autocomplete_view) + TextInputLayoutBindingAdapters.setLanguageSelection(testView, OppiaLanguage.ENGLISH, false) + assertThat(testView.text.toString()).isEqualTo("English") + } + } + } + + @Test + fun testBindingAdapters_setSelection_filterEnabled_setsSelectionCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: AutoCompleteTextView = activity.findViewById(R.id.test_autocomplete_view) + TextInputLayoutBindingAdapters.setLanguageSelection(testView, OppiaLanguage.ENGLISH, true) + assertThat(testView.text.toString()).isEqualTo("English") + } + } + } + + @Test + fun testBindingAdapters_setSelection_arabicLanguage_setsSelectionCorrectly() { + launchActivity().use { scenario -> + scenario?.onActivity { activity -> + val testView: AutoCompleteTextView = activity.findViewById(R.id.test_autocomplete_view) + TextInputLayoutBindingAdapters.setLanguageSelection(testView, OppiaLanguage.ARABIC, true) + assertThat(testView.text.toString()).isEqualTo( + context.getString(R.string.arabic_localized_language_name) + ) + } + } + } + + private fun launchActivity(): + ActivityScenario? { + val scenario = ActivityScenario.launch( + TextInputLayoutBindingAdaptersTestActivity.createIntent(context) + ) + testCoroutineDispatchers.runCurrent() + return scenario + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. + @Singleton + @Component( + modules = [ + RobolectricModule::class, + PlatformParameterModule::class, PlatformParameterSingletonModule::class, + TestDispatcherModule::class, ApplicationModule::class, + LoggerModule::class, ContinueModule::class, FractionInputModule::class, + ItemSelectionInputModule::class, MultipleChoiceInputModule::class, + NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, + DragDropSortInputModule::class, ImageClickInputModule::class, InteractionsModule::class, + GcsResourceModule::class, TestImageLoaderModule::class, ImageParsingModule::class, + HtmlParserEntityTypeModule::class, QuestionModule::class, TestLogReportingModule::class, + AccessibilityTestModule::class, LogStorageModule::class, CachingTestModule::class, + ExpirationMetaDataRetrieverModule::class, + ViewBindingShimModule::class, RatioInputModule::class, WorkManagerConfigurationModule::class, + ApplicationStartupListenerModule::class, LogReportWorkerModule::class, + HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class, + FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, + DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, + ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, LocaleProdModule::class, ActivityRecreatorTestModule::class, + NumericExpressionInputModule::class, AlgebraicExpressionInputModule::class, + MathEquationInputModule::class, SplitScreenInteractionModule::class, + LoggingIdentifierModule::class, ApplicationLifecycleModule::class, + SyncStatusModule::class, MetricLogSchedulerModule::class, TestingBuildFlavorModule::class, + EventLoggingConfigurationModule::class, ActivityRouterModule::class, + CpuPerformanceSnapshotterModule::class, ExplorationProgressModule::class, + TestAuthenticationModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder : ApplicationComponent.Builder { + override fun build(): TestApplicationComponent + } + + fun inject(textInputLayoutBindingAdaptersTest: TextInputLayoutBindingAdaptersTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerTextInputLayoutBindingAdaptersTest_TestApplicationComponent.builder() + .setApplication(this) + .build() as TestApplicationComponent + } + + fun inject(textInputLayoutBindingAdaptersTest: TextInputLayoutBindingAdaptersTest) { + component.inject(textInputLayoutBindingAdaptersTest) + } + + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() + } + + override fun getApplicationInjector(): ApplicationInjector = component + } +} diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt index 5e9c8ada80e..c59489c20c9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt @@ -20,6 +20,7 @@ import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -28,13 +29,10 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.google.protobuf.MessageLite import dagger.Component -import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not -import org.hamcrest.TypeSafeMatcher import org.junit.After import org.junit.Before import org.junit.Rule @@ -52,10 +50,14 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.CreateProfileActivityParams import org.oppia.android.app.model.IntroActivityParams +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ProfileType import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.app.utility.EspressoTestsMatchers.hasProtoExtra import org.oppia.android.app.utility.OrientationChangeAction.Companion.orientationLandscape import org.oppia.android.data.backends.gae.NetworkConfigProdModule import org.oppia.android.data.backends.gae.NetworkModule @@ -87,6 +89,7 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.DisableAccessibilityChecks import org.oppia.android.testing.OppiaTestRule import org.oppia.android.testing.TestImageLoaderModule import org.oppia.android.testing.TestLogReportingModule @@ -94,6 +97,7 @@ import org.oppia.android.testing.espresso.EditTextInputAction import org.oppia.android.testing.firebase.TestAuthenticationModule import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule +import org.oppia.android.testing.profile.ProfileTestHelper import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule @@ -101,7 +105,7 @@ import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule -import org.oppia.android.util.extensions.getProtoExtra +import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EventLoggingConfigurationModule @@ -113,6 +117,8 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.ImageParsingModule import org.oppia.android.util.parser.image.TestGlideImageLoader +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -128,18 +134,27 @@ import javax.inject.Singleton qualifiers = "port-xxhdpi" ) class CreateProfileFragmentTest { - @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() - @get:Rule val oppiaTestRule = OppiaTestRule() - @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - @Inject lateinit var context: Context - @Inject lateinit var editTextInputAction: EditTextInputAction - @Inject lateinit var testGlideImageLoader: TestGlideImageLoader + @get:Rule + val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() + @get:Rule + val oppiaTestRule = OppiaTestRule() + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + lateinit var context: Context + @Inject + lateinit var editTextInputAction: EditTextInputAction + @Inject + lateinit var testGlideImageLoader: TestGlideImageLoader + @Inject + lateinit var profileTestHelper: ProfileTestHelper @Before fun setUp() { Intents.init() setUpTestApplicationComponent() testCoroutineDispatchers.registerIdlingResource() + profileTestHelper.createDefaultAdminProfile() } @After @@ -195,6 +210,7 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -203,13 +219,15 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } } @Test + @DisableAccessibilityChecks fun testFragment_continueButtonClicked_filledNickname_doesNotShowErrorText() { launchNewLearnerProfileActivity().use { onView(withId(R.id.create_profile_nickname_edittext)) @@ -218,11 +236,12 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() - onView(withText(R.string.create_profile_activity_nickname_error)) + onView(withId(R.id.create_profile_nickname_error)) .check(matches(withEffectiveVisibility(Visibility.GONE))) } } @@ -244,6 +263,7 @@ class CreateProfileFragmentTest { onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() + onView(withText(R.string.create_profile_activity_nickname_error)) .check(matches(isDisplayed())) @@ -253,6 +273,7 @@ class CreateProfileFragmentTest { closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -261,7 +282,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -290,12 +312,15 @@ class CreateProfileFragmentTest { @Test fun testFragment_landscapeMode_filledNickname_continueButtonClicked_launchesLearnerIntroScreen() { launchNewLearnerProfileActivity().use { + onView(isRoot()).perform(orientationLandscape()) + onView(withId(R.id.create_profile_nickname_edittext)) .perform( editTextInputAction.appendText("John"), closeSoftKeyboard() ) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)) .perform(click()) testCoroutineDispatchers.runCurrent() @@ -304,7 +329,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -365,7 +391,8 @@ class CreateProfileFragmentTest { intended( allOf( hasComponent(IntroActivity::class.java.name), - hasProtoExtra("OnboardingIntroActivity.params", expectedParams) + hasProtoExtra(IntroActivity.PARAMS_KEY, expectedParams), + hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) ) ) } @@ -451,6 +478,155 @@ class CreateProfileFragmentTest { } } + @Test + fun testFragment_inputNameWithNumbers_showsNameOnlyLettersError() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + } + } + + @Test + fun testFragment_landscape_inputNameWithNumbers_showsNameOnlyLettersError() { + launchNewLearnerProfileActivity().use { + onView(isRoot()).perform(orientationLandscape()) + + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + } + } + + @Test + fun testFragment_inputNameWithNumbers_configChange_errorIsRetained() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + onView(withText(R.string.add_profile_error_name_only_letters)) + .check(matches(withEffectiveVisibility(Visibility.VISIBLE))) + } + } + + @Test + fun testFragment_inputNameWithNumbers_thenInputNameWithLetters_errorIsCleared() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withEffectiveVisibility(Visibility.GONE))) + } + } + + @Test + fun testFragment_inputNameWithNumbers_configChange_thenInputNameWithLetters_errorIsCleared() { + launchNewLearnerProfileActivity().use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John123"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_name_only_letters))) + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John"), + closeSoftKeyboard() + ) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withEffectiveVisibility(Visibility.GONE))) + } + } + + @Test + fun testFragment_profileTypeArgumentMissing_showsUnknownProfileTypeError() { + val intent = CreateProfileActivity.createProfileActivityIntent(context) + // Not adding the profile type intent parameter to trigger the exception. + intent.decorateWithUserProfileId(ProfileId.newBuilder().setInternalId(0).build()) + + val scenario = ActivityScenario.launch(intent) + testCoroutineDispatchers.runCurrent() + + scenario.use { + onView(withId(R.id.create_profile_nickname_edittext)) + .perform( + editTextInputAction.appendText("John"), + closeSoftKeyboard() + ) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.create_profile_nickname_error)) + .check(matches(withText(R.string.add_profile_error_missing_profile_type))) + } + } + private fun createGalleryPickActivityResultStub(): Instrumentation.ActivityResult { val resources: Resources = context.resources val imageUri = Uri.parse( @@ -466,27 +642,19 @@ class CreateProfileFragmentTest { private fun launchNewLearnerProfileActivity(): ActivityScenario? { - val scenario = ActivityScenario.launch( - CreateProfileActivity.createProfileActivityIntent(context) + val intent = CreateProfileActivity.createProfileActivityIntent(context) + intent.decorateWithUserProfileId(ProfileId.newBuilder().setInternalId(0).build()) + intent.putProtoExtra( + CREATE_PROFILE_PARAMS_KEY, + CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() ) + val scenario = ActivityScenario.launch(intent) testCoroutineDispatchers.runCurrent() return scenario } - private fun hasProtoExtra(keyName: String, expectedProto: T): Matcher { - val defaultProto = expectedProto.newBuilderForType().build() - return object : TypeSafeMatcher() { - override fun describeTo(description: Description) { - description.appendText("Intent with extra: $keyName and proto value: $expectedProto") - } - - override fun matchesSafely(intent: Intent): Boolean { - return intent.hasExtra(keyName) && - intent.getProtoExtra(keyName, defaultProto) == expectedProto - } - } - } - private fun setUpTestApplicationComponent() { ApplicationProvider.getApplicationContext().inject(this) } diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt index 11ded15d116..73dbd70e492 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroActivityTest.kt @@ -110,8 +110,6 @@ class IntroActivityTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - private val testProfileNickname = "John" - @Before fun setUp() { Intents.init() @@ -126,10 +124,7 @@ class IntroActivityTest { @Test fun testActivity_createIntent_verifyScreenNameInIntent() { val screenName = - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context) .extractCurrentAppScreenName() assertThat(screenName).isEqualTo(ScreenName.INTRO_ACTIVITY) @@ -151,10 +146,7 @@ class IntroActivityTest { private fun launchOnboardingLearnerIntroActivity(): ActivityScenario? { val scenario = ActivityScenario.launch( - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context) ) testCoroutineDispatchers.runCurrent() return scenario diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt index 72fea853fbc..c72f4e5721b 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt @@ -9,6 +9,9 @@ import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId @@ -33,6 +36,8 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.IntroActivityParams +import org.oppia.android.app.options.AudioLanguageActivity import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule @@ -79,6 +84,7 @@ import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule +import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EventLoggingConfigurationModule @@ -90,6 +96,7 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -185,21 +192,8 @@ class IntroFragmentTest { onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.onboarding_learner_intro_title)) - .check(matches(withText("Welcome, John!"))) - onView(withText(R.string.onboarding_learner_intro_classroom_text)) - .check(matches(isDisplayed())) - onView(withText(R.string.onboarding_learner_intro_practice_text)) - .check(matches(isDisplayed())) - onView( - withText( - context.getString( - R.string.onboarding_learner_intro_feedback_text, - context.getString(R.string.app_name) - ) - ) - ).check(matches(isDisplayed())) + intended(hasComponent(AudioLanguageActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) } } @@ -211,31 +205,21 @@ class IntroFragmentTest { onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.onboarding_learner_intro_title)) - .check(matches(withText("Welcome, John!"))) - onView(withText(R.string.onboarding_learner_intro_classroom_text)) - .check(matches(isDisplayed())) - onView(withText(R.string.onboarding_learner_intro_practice_text)) - .check(matches(isDisplayed())) - onView( - withText( - context.getString( - R.string.onboarding_learner_intro_feedback_text, - context.getString(R.string.app_name) - ) - ) - ).check(matches(isDisplayed())) + intended(hasComponent(AudioLanguageActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) } } private fun launchOnboardingLearnerIntroActivity(): ActivityScenario? { + val params = IntroActivityParams.newBuilder() + .setProfileNickname(testProfileNickname) + .build() + val scenario = ActivityScenario.launch( - IntroActivity.createIntroActivity( - context, - testProfileNickname - ) + IntroActivity.createIntroActivity(context).apply { + putProtoExtra(IntroActivity.PARAMS_KEY, params) + } ) testCoroutineDispatchers.runCurrent() return scenario diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt index 46f1b08fe88..b7c3f0f5231 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt @@ -5,8 +5,10 @@ import android.content.Context import android.content.res.Resources import android.view.View import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction @@ -19,6 +21,8 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey +import androidx.test.espresso.matcher.RootMatchers.withDecorView import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -29,10 +33,13 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.viewpager2.widget.ViewPager2 +import com.google.common.truth.Truth.assertThat import dagger.Component +import org.hamcrest.CoreMatchers.`is` import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not import org.hamcrest.Matcher +import org.hamcrest.core.IsInstanceOf.instanceOf import org.junit.After import org.junit.Rule import org.junit.Test @@ -49,6 +56,7 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.shim.ViewBindingShimModule @@ -85,9 +93,13 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.BuildEnvironment import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.TestPlatform import org.oppia.android.testing.firebase.TestAuthenticationModule +import org.oppia.android.testing.junit.DefineAppLanguageLocaleContext import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule import org.oppia.android.testing.robolectric.RobolectricModule @@ -97,7 +109,6 @@ import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule -import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.locale.LocaleProdModule import org.oppia.android.util.logging.EventLoggingConfigurationModule @@ -106,12 +117,13 @@ import org.oppia.android.util.logging.SyncStatusModule import org.oppia.android.util.logging.firebase.FirebaseLogUploaderModule import org.oppia.android.util.networking.NetworkConnectionDebugUtilModule import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule -import org.oppia.android.util.parser.html.HtmlParser import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -132,19 +144,12 @@ class OnboardingFragmentTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - @Inject - lateinit var htmlParserFactory: HtmlParser.Factory - @Inject lateinit var context: Context @Inject lateinit var appLanguageLocaleHandler: AppLanguageLocaleHandler - @Inject - @field:DefaultResourceBucketName - lateinit var resourceBucketName: String - @After fun tearDown() { testCoroutineDispatchers.unregisterIdlingResource() @@ -792,6 +797,299 @@ class OnboardingFragmentTest { } } + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_englishLocale_englishIsPreselected() { + setUpTestWithOnboardingV2Enabled() + + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.ENGLISH) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.english_localized_language_name)) + ) + } + } + + @Test + fun testOnboardingFragment_onboardingV2Enabled_englishLocale_layoutIsLtr() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.ARABIC_VALUE, + appStringIetfTag = "ar", + appStringAndroidLanguageId = "ar" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_arabicLocale_arabicIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(EGYPT_ARABIC_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.ARABIC) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.arabic_localized_language_name)) + ) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.ARABIC_VALUE, + appStringIetfTag = "ar", + appStringAndroidLanguageId = "ar" + ) + @RunOn(TestPlatform.ROBOLECTRIC) + fun testOnboardingFragment_onboardingV2Enabled_arabicLocale_layoutIsRtl() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(EGYPT_ARABIC_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_RTL) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.BRAZILIAN_PORTUGUESE_VALUE, + appStringIetfTag = "pt-BR", + appStringAndroidLanguageId = "pt", + appStringAndroidRegionId = "BR" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_portugueseLocale_portugueseIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(BRAZIL_PORTUGUESE_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.BRAZILIAN_PORTUGUESE) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.portuguese_localized_language_name)) + ) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.BRAZILIAN_PORTUGUESE_VALUE, + appStringIetfTag = "pt-BR", + appStringAndroidLanguageId = "pt", + appStringAndroidRegionId = "BR" + ) + @RunOn(TestPlatform.ROBOLECTRIC) + fun testOnboardingFragment_onboardingV2Enabled_portugueseLocale_layoutIsLtr() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(BRAZIL_PORTUGUESE_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.NIGERIAN_PIDGIN_VALUE, + appStringIetfTag = "pcm", + appStringAndroidLanguageId = "pcm", + appStringAndroidRegionId = "NG" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_nigeriaLocale_naijaIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(NIGERIA_NAIJA_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.NIGERIAN_PIDGIN) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.NIGERIAN_PIDGIN_VALUE, + appStringIetfTag = "pcm", + appStringAndroidLanguageId = "pcm", + appStringAndroidRegionId = "NG" + ) + @RunOn(TestPlatform.ROBOLECTRIC) + fun testOnboardingFragment_onboardingV2Enabled_nigeriaLocale_layoutIsLtr() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(NIGERIA_NAIJA_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val layoutDirection = displayLocale.getLayoutDirection() + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + } + + @Test + @DefineAppLanguageLocaleContext( + oppiaLanguageEnumId = OppiaLanguage.LANGUAGE_UNSPECIFIED_VALUE, + appStringIetfTag = "fr", + appStringAndroidLanguageId = "fr-CA", + appStringAndroidRegionId = "CA" + ) + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testOnboardingFragment_onboardingV2Enabled_unsupportedLocale_englishIsPreselected() { + setUpTestWithOnboardingV2Enabled() + forceDefaultLocale(CANADA_FRENCH_LOCALE) + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Verify that the display locale is set up correctly (for string formatting). + val displayLocale = appLanguageLocaleHandler.getDisplayLocale() + val localeContext = displayLocale.localeContext + assertThat(localeContext.languageDefinition.language) + .isEqualTo(OppiaLanguage.LANGUAGE_UNSPECIFIED) + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.english_localized_language_name)) + ) + } + } + + @Test + fun testFragment_onboardingV2Enabled_clickLetsGoButton_launchesProfileTypeScreen() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + // Verifies that the default language selection is set if the user does not make a selection. + onView(withId(R.id.onboarding_language_lets_go_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(OnboardingProfileTypeActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_onboardingV2_languageSelectionChanged_languageIsUpdated() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.onboarding_language_dropdown)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + + onView(withId(R.id.onboarding_language_lets_go_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(OnboardingProfileTypeActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + } + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_onboardingV2_languageSelectionChanged_configChange_languageIsUpdated() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.onboarding_language_dropdown)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + onView(isRoot()).perform(orientationLandscape()) + + testCoroutineDispatchers.runCurrent() + + // Verifies that the selected language is still set successfully after configuration change. + onView(withId(R.id.onboarding_language_lets_go_button)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(OnboardingProfileTypeActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + } + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_onboardingV2_orientationChange_languageSelectionIsRestored() { + setUpTestWithOnboardingV2Enabled() + launch(OnboardingActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.onboarding_language_dropdown)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.onboarding_language_dropdown)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + } + } + } + + private fun forceDefaultLocale(locale: Locale) { + context.applicationContext.resources.configuration.setLocale(locale) + Locale.setDefault(locale) + } + private fun setUpTestWithOnboardingV2Disabled() { TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) setUp() @@ -890,4 +1188,11 @@ class OnboardingFragmentTest { override fun getApplicationInjector(): ApplicationInjector = component } + + private companion object { + private val BRAZIL_PORTUGUESE_LOCALE = Locale("pt", "BR") + private val EGYPT_ARABIC_LOCALE = Locale("ar", "EG") + private val NIGERIA_NAIJA_LOCALE = Locale("pcm", "NG") + private val CANADA_FRENCH_LOCALE = Locale("fr", "CA") + } } diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt index 2649e11c610..fbeb04c4f11 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingProfileTypeFragmentTest.kt @@ -11,6 +11,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -37,10 +38,13 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.model.CreateProfileActivityParams +import org.oppia.android.app.model.ProfileType import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.app.utility.EspressoTestsMatchers.hasProtoExtra import org.oppia.android.app.utility.OrientationChangeAction.Companion.orientationLandscape import org.oppia.android.data.backends.gae.NetworkConfigProdModule import org.oppia.android.data.backends.gae.NetworkModule @@ -96,6 +100,7 @@ import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.oppia.android.util.parser.html.HtmlParserEntityTypeModule import org.oppia.android.util.parser.image.GlideImageLoaderModule import org.oppia.android.util.parser.image.ImageParsingModule +import org.oppia.android.util.profile.PROFILE_ID_INTENT_DECORATOR import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import javax.inject.Inject @@ -257,12 +262,14 @@ class OnboardingProfileTypeFragmentTest { launchOnboardingProfileTypeActivity().use { onView(withId(R.id.profile_type_learner_navigation_card)).perform(click()) testCoroutineDispatchers.runCurrent() - // Does nothing for now, but should fail once navigation is implemented in a future PR. - onView(withId(R.id.profile_type_learner_navigation_card)) - .check(matches(isDisplayed())) - onView(withId(R.id.profile_type_supervisor_navigation_card)) - .check(matches(isDisplayed())) + val params = CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() + + intended(hasComponent(CreateProfileActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + intended(hasProtoExtra(CREATE_PROFILE_PARAMS_KEY, params)) } } @@ -271,9 +278,17 @@ class OnboardingProfileTypeFragmentTest { launchOnboardingProfileTypeActivity().use { onView(isRoot()).perform(orientationLandscape()) testCoroutineDispatchers.runCurrent() + onView(withId(R.id.profile_type_learner_navigation_card)).perform(click()) testCoroutineDispatchers.runCurrent() + + val params = CreateProfileActivityParams.newBuilder() + .setProfileType(ProfileType.SOLE_LEARNER) + .build() + intended(hasComponent(CreateProfileActivity::class.java.name)) + intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) + intended(hasProtoExtra(CREATE_PROFILE_PARAMS_KEY, params)) } } diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt index b8aed771aaa..8195ac0f683 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt @@ -6,10 +6,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onData import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers.withDecorView import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isRoot @@ -19,6 +23,11 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import dagger.Component +import org.hamcrest.CoreMatchers +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.CoreMatchers.not +import org.hamcrest.core.AllOf.allOf import org.junit.After import org.junit.Rule import org.junit.Test @@ -35,6 +44,7 @@ import org.oppia.android.app.application.ApplicationStartupListenerModule import org.oppia.android.app.application.testing.TestingBuildFlavorModule import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule +import org.oppia.android.app.home.HomeActivity import org.oppia.android.app.model.AudioLanguage import org.oppia.android.app.model.AudioLanguage.BRAZILIAN_PORTUGUESE_LANGUAGE import org.oppia.android.app.model.AudioLanguage.ENGLISH_AUDIO_LANGUAGE @@ -75,8 +85,11 @@ import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.BuildEnvironment import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.TestPlatform import org.oppia.android.testing.firebase.TestAuthenticationModule import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule @@ -311,22 +324,13 @@ class AudioLanguageFragmentTest { launch( createDefaultAudioActivityIntent(ENGLISH_AUDIO_LANGUAGE) ).use { + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.audio_language_text)).check( - matches(withText("In Oppia, you can listen to lessons!")) - ) - onView(withId(R.id.audio_language_subtitle)).check( - matches(withText(context.getString(R.string.audio_language_fragment_subtitle))) - ) - onView(withId(R.id.onboarding_navigation_back)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) - onView(withId(R.id.onboarding_navigation_continue)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) + // Verifies that accepting the default language selection works correctly. + intended(hasComponent(HomeActivity::class.java.name)) } } @@ -341,19 +345,73 @@ class AudioLanguageFragmentTest { onView(withId(R.id.onboarding_navigation_continue)).perform(click()) testCoroutineDispatchers.runCurrent() - // Do nothing for now, but will fail once navigation is implemented - onView(withId(R.id.audio_language_text)).check( - matches(withText("In Oppia, you can listen to lessons!")) - ) - onView(withId(R.id.audio_language_subtitle)).check( - matches(withText(context.getString(R.string.audio_language_fragment_subtitle))) - ) - onView(withId(R.id.onboarding_navigation_back)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) - onView(withId(R.id.onboarding_navigation_continue)).check( - matches(withEffectiveVisibility(Visibility.VISIBLE)) - ) + // Verifies that accepting the default language selection works correctly. + intended(hasComponent(HomeActivity::class.java.name)) + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_languageSelectionChanged_selectionIsUpdated() { + initializeTestApplicationComponent(enableOnboardingFlowV2 = true) + launch( + createDefaultAudioActivityIntent(ENGLISH_AUDIO_LANGUAGE) + ).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.audio_language_dropdown_list)).perform(click()) + + onData(allOf(`is`(instanceOf(String::class.java)), `is`("Naijá"))) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(withId(R.id.audio_language_dropdown_list)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(HomeActivity::class.java.name)) + } + } + } + + @Test + @RunOn(TestPlatform.ROBOLECTRIC, buildEnvironments = [BuildEnvironment.BAZEL]) + fun testFragment_languageSelectionChanged_configChange_selectionIsUpdated() { + initializeTestApplicationComponent(enableOnboardingFlowV2 = true) + launch( + createDefaultAudioActivityIntent(ENGLISH_AUDIO_LANGUAGE) + ).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + onView(withId(R.id.audio_language_dropdown_list)).perform(click()) + + onData( + CoreMatchers.allOf( + `is`(instanceOf(String::class.java)), `is`("Naijá") + ) + ) + .inRoot(withDecorView(not(`is`(activity.window.decorView)))) + .perform(click()) + + onView(isRoot()).perform(orientationLandscape()) + testCoroutineDispatchers.runCurrent() + + // Verifies that the selected language is still set successfully after configuration change. + onView(withId(R.id.audio_language_dropdown_list)).check( + matches(withText(R.string.nigerian_pidgin_localized_language_name)) + ) + + onView(withId(R.id.onboarding_navigation_continue)).perform(click()) + testCoroutineDispatchers.runCurrent() + + intended(hasComponent(HomeActivity::class.java.name)) + } } } @@ -512,9 +570,7 @@ class AudioLanguageFragmentTest { ) interface TestApplicationComponent : ApplicationComponent { @Component.Builder - interface Builder : ApplicationComponent.Builder { - override fun build(): TestApplicationComponent - } + interface Builder : ApplicationComponent.Builder fun inject(audioLanguageFragmentTest: AudioLanguageFragmentTest) } diff --git a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt index 3f69bfff0d6..2d529beffd9 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 @@ -141,18 +140,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/assets/accessibility_label_exemptions.textproto b/scripts/assets/accessibility_label_exemptions.textproto index a1993f3b4dd..206b77a0466 100644 --- a/scripts/assets/accessibility_label_exemptions.textproto +++ b/scripts/assets/accessibility_label_exemptions.textproto @@ -36,6 +36,7 @@ exempted_activity: "app/src/main/java/org/oppia/android/app/testing/SplashTestAc exempted_activity: "app/src/main/java/org/oppia/android/app/testing/StateAssemblerMarginBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/StateAssemblerPaddingBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivity" +exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TextViewBindingAdaptersTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TopicRevisionTestActivity" exempted_activity: "app/src/main/java/org/oppia/android/app/testing/TopicTestActivity" diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index d4e3597a664..fe0d7d9aba9 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -276,6 +276,7 @@ file_content_checks { exempted_file_name: "app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt" + exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/parser/HtmlParserTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/parser/ListItemLeadingMarginSpanTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/player/audio/AudioFragmentTest.kt" diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index bbc8a2fd872..9bf4d08e951 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -1342,6 +1342,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/onboarding/OnboardingAppLanguageViewModel.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewPagerViewModel.kt" test_file_not_required: true @@ -2378,6 +2382,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/BindableAdapterTestActivity.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestActivity.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt" test_file_not_required: true @@ -2410,6 +2418,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/ColorBindingAdaptersTestFragment.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/TextInputLayoutBindingAdaptersTestFragment.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivity.kt" test_file_not_required: true diff --git a/scripts/buf_lint_check.sh b/scripts/buf_lint_check.sh index c5cbfed35ec..b355f2af16b 100644 --- a/scripts/buf_lint_check.sh +++ b/scripts/buf_lint_check.sh @@ -1,5 +1,7 @@ #!/bin/bash +source scripts/formatting.sh + jar_file_path=$? config_file_path=$? os_type=$? @@ -15,11 +17,11 @@ lint_protobuf_files() { status=$? if [ "$status" = 0 ] ; then - echo "Protobuf lint check completed successfully" + echo_success "Protobuf lint check completed successfully" exit 0 else echo "********************************" - echo "Protobuf lint check issues found. Please fix them before pushing your code." + echo_error "Protobuf lint check issues found. Please fix them before pushing your code." echo "********************************" exit 1 fi @@ -57,7 +59,7 @@ check_os_type() { elif [[ "$OSTYPE" == "darwin"* ]]; then os_type="Darwin" else - echo "Protobuf lint check not available on $OSTYPE" + echo_error "Protobuf lint check not available on $OSTYPE" exit 0 fi } diff --git a/scripts/buildifier_lint_check.sh b/scripts/buildifier_lint_check.sh index 52e0857468e..b56487f6b2f 100644 --- a/scripts/buildifier_lint_check.sh +++ b/scripts/buildifier_lint_check.sh @@ -1,5 +1,7 @@ #!/bin/bash +source scripts/formatting.sh + echo "********************************" echo "Checking Bazel file formatting" echo "********************************" @@ -19,12 +21,12 @@ $buildifier_file_path --lint=warn --mode=check --warnings=-native-android,+out-o status=$? if [ "$status" = 0 ] ; then - echo "Buildifier lint check completed successfully" + echo_success "Buildifier lint check completed successfully" exit 0 else # Assume any lint output or non-zero exit code is a failure. echo "********************************" - echo "Buildifier issue found." + echo_error "Buildifier issue found." echo "Please fix the above issues." echo "********************************" exit 1 diff --git a/scripts/checkstyle_lint_check.sh b/scripts/checkstyle_lint_check.sh index 668be1ba670..1b682d8b615 100644 --- a/scripts/checkstyle_lint_check.sh +++ b/scripts/checkstyle_lint_check.sh @@ -1,5 +1,7 @@ #!/bin/bash +source scripts/formatting.sh + echo "********************************" echo "Checking Java file formatting" echo "********************************" @@ -23,11 +25,11 @@ echo $lint_results if [ "$lint_command_result" -ne 0 ] || [ -z "$lint_results" ] || [[ ${lint_results} == *"[WARN]"* ]]; then # Assume any lint output or non-zero exit code is a failure. echo "********************************" - echo "Checkstyle issue found." + echo_error "Checkstyle issue found." echo "Please fix the above issues." echo "********************************" exit 1 else - echo "Checkstyle lint check completed successfully" + echo_success "Checkstyle lint check completed successfully" exit 0 fi diff --git a/scripts/feature_flags_check.sh b/scripts/feature_flags_check.sh index 48e61153a70..92b0e07a10e 100644 --- a/scripts/feature_flags_check.sh +++ b/scripts/feature_flags_check.sh @@ -1,5 +1,7 @@ #!/bin/bash +source scripts/formatting.sh + echo "********************************" echo "Running feature flag checks" echo "********************************" @@ -120,7 +122,7 @@ function perform_checks_on_feature_flags() { in_array=$(item_in_array "$element" "${imported_classes[@]}") if [[ $in_array -ne 1 ]]; then failed_checks=$((failed_checks + 1)) - echo "$element is not imported in the constructor argument in $file_path at line $imports_line_number" + echo_error "$element is not imported in the constructor argument in $file_path at line $imports_line_number" fi done @@ -128,16 +130,16 @@ function perform_checks_on_feature_flags() { in_array=$(item_in_array "$element" "${flags_added_to_map[@]}") if [[ $in_array -ne 1 ]]; then failed_checks=$((failed_checks + 1)) - echo "$element is not added to the logging map in $file_path at line $flags_map_line_number" + echo_error "$element is not added to the logging map in $file_path at line $flags_map_line_number" fi done if [[ $failed_checks -eq 0 ]]; then - echo "Feature flag checks completed successfully" + echo_success "Feature flag checks completed successfully" exit 0 else echo "********************************" - echo "Feature flag issues found." + echo_error "Feature flag issues found." echo "Please fix the above issues." echo "********************************" exit 1 diff --git a/scripts/formatting.sh b/scripts/formatting.sh new file mode 100644 index 00000000000..0fd2c2dac94 --- /dev/null +++ b/scripts/formatting.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +#Defines color codes for output formatting + +# Red color for error messages +RED='\033[0;31m' + +# Green color for success messages +GREEN='\033[0;32m' + +# Yellow color for warnings messages +YELLOW='\033[0;33m' + +# No color, used to reset the color after each message +NC='\033[0m' + +# Function to print an error message in red +function echo_error() { + echo -e "${RED}$1${NC}" +} + +# Function to print a success message in green +function echo_success() { + echo -e "${GREEN}$1${NC}" +} + +# Function to print a warning message in yellow +function echo_warning() { + echo -e "${YELLOW}$1${NC}" +} diff --git a/scripts/ktlint_lint_check.sh b/scripts/ktlint_lint_check.sh index 2713b88d0e0..dab25e7bd64 100755 --- a/scripts/ktlint_lint_check.sh +++ b/scripts/ktlint_lint_check.sh @@ -1,5 +1,7 @@ #!/bin/bash +source scripts/formatting.sh + echo "********************************" echo "Checking code formatting" echo "********************************" @@ -19,15 +21,15 @@ java -jar $jar_file_path --android app/src/**/*.kt data/src/**/*.kt domain/src/* status=$? if [ "$status" = 0 ] ; then - echo "Lint completed successfully." + echo_success "Lint completed successfully." exit 0 else echo "********************************" - echo "Ktlint issue found." + echo_error "Ktlint issue found." echo "Please fix the above issues. You can also use the java -jar $jar_file_path -F --android domain/src/**/*.kt utility/src/**/*.kt data/src/**/*.kt app/src/**/*.kt testing/src/**/*.kt scripts/src/**/*.kt instrumentation/src/**/*.kt command to fix the most common issues." - echo "Please note, there might be a possibility where the above command will not fix the issue. + echo_warning "Please note, there might be a possibility where the above command will not fix the issue. In that case, you will have to fix it yourself." echo "********************************" exit 1 diff --git a/scripts/pre-push.sh b/scripts/pre-push.sh index 13d37eeb20e..ea2878926de 100755 --- a/scripts/pre-push.sh +++ b/scripts/pre-push.sh @@ -1,5 +1,7 @@ #!/bin/bash +source scripts/formatting.sh + # This script will run the pre-push checks in the given order # - ktlint # - checkstyle @@ -7,7 +9,7 @@ # - (others in the future) if bash scripts/ktlint_lint_check.sh && bash scripts/checkstyle_lint_check.sh && bash scripts/buf_lint_check.sh ; then - echo "All checks passed successfully" + echo_success "All checks passed successfully" exit 0 else exit 1 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/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/_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)