diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7691a6416a9..1fb3ed63b53 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -370,6 +370,10 @@ android:name=".app.onboarding.AdminIntroActivity" android:label="@string/admin_intro_activity_title" android:theme="@style/OppiaThemeWithoutActionBar" /> + diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt index d26c4ca132d..6aafb79490f 100644 --- a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt @@ -52,6 +52,7 @@ import org.oppia.android.app.policies.PoliciesActivity import org.oppia.android.app.profile.AddProfileActivity import org.oppia.android.app.profile.AdminAuthActivity import org.oppia.android.app.profile.AdminPinActivity +import org.oppia.android.app.profile.CreateAdminPinActivity import org.oppia.android.app.profile.PinPasswordActivity import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.profile.ProfileLoginActivity @@ -236,4 +237,5 @@ interface ActivityComponentImpl : fun inject(introActivity: IntroActivity) fun inject(profileLoginActivity: ProfileLoginActivity) fun inject(adminIntroActivity: AdminIntroActivity) + fun inject(createAdminPinActivity: CreateAdminPinActivity) } diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt index 3f6883435ec..219b6fcfc5c 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt @@ -64,6 +64,7 @@ import org.oppia.android.app.player.stopplaying.StopExplorationDialogFragment import org.oppia.android.app.player.stopplaying.UnsavedExplorationDialogFragment import org.oppia.android.app.policies.PoliciesFragment import org.oppia.android.app.profile.AdminSettingsDialogFragment +import org.oppia.android.app.profile.CreateAdminPinFragment import org.oppia.android.app.profile.ProfileChooserFragment import org.oppia.android.app.profile.ProfileLoginFragment import org.oppia.android.app.profile.ResetPinDialogFragment @@ -222,4 +223,5 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto fun inject(introFragment: IntroFragment) fun inject(profileLoginFragment: ProfileLoginFragment) fun inject(adminIntroFragment: AdminIntroFragment) + fun inject(createAdminPinFragment: CreateAdminPinFragment) } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/AdminIntroFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/AdminIntroFragmentPresenter.kt index b268d1e7fa5..dd8cab51f38 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/AdminIntroFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/AdminIntroFragmentPresenter.kt @@ -45,18 +45,15 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.Fragment import org.oppia.android.app.databinding.databinding.AdminIntroFragmentBinding import org.oppia.android.app.model.LegacyProfileId -import org.oppia.android.app.model.ProfileChooserActivityParams -import org.oppia.android.app.model.ProfileChooserActivityParams.ParentScreen import org.oppia.android.app.model.ProfileType +import org.oppia.android.app.profile.CreateAdminPinActivity import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.ui.R import org.oppia.android.app.utility.edgetoedge.EdgeToEdgeHelper import org.oppia.android.domain.profile.ProfileManagementController -import org.oppia.android.util.extensions.putProtoExtra import org.oppia.android.util.platformparameter.EnableEdgeToEdge import org.oppia.android.util.platformparameter.PlatformParameterValue -import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import org.oppia.android.util.profile.toProfileIdPreservingZero import javax.inject.Inject @@ -274,8 +271,9 @@ class AdminIntroFragmentPresenter @Inject constructor( Button( onClick = { - // TODO(#4938): Refactor to: create admin pin screen when the UI is ready. - navigateToProfileChooserActivity() + fragment.startActivity( + CreateAdminPinActivity.createAdminPinActivityIntent(activity, profileId) + ) }, colors = ButtonDefaults.buttonColors( backgroundColor = colorResource( @@ -297,22 +295,4 @@ class AdminIntroFragmentPresenter @Inject constructor( } } } - - private fun navigateToProfileChooserActivity() { - val intent = ProfileChooserActivity.createProfileChooserActivity(activity) - intent.apply { - decorateWithUserProfileId(profileId) - putProtoExtra( - PROFILE_CHOOSER_PARAMS_KEY, - ProfileChooserActivityParams.newBuilder() - .setParentScreen(ParentScreen.ADMIN_INTRO_SCREEN) - .build() - ) - } - 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() - } } 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 54f2ae8ec17..d8bb569e168 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 @@ -99,7 +99,7 @@ class CreateProfileFragmentPresenter @Inject constructor( val nickname = binding.createProfileNicknameEdittext.text.toString().trim() if (!checkNicknameAndUpdateError(nickname)) { - updateProfileDetails(nickname) + updateProfileDetails(nickname, profileType) } } @@ -163,7 +163,7 @@ class CreateProfileFragmentPresenter @Inject constructor( } } - private fun updateProfileDetails(profileName: String) { + private fun updateProfileDetails(profileName: String, profileType: ProfileType) { profileManagementController.updateNewProfileDetails( profileId = profileId.toProfileIdPreservingZero(), profileType = profileType, @@ -183,7 +183,8 @@ class CreateProfileFragmentPresenter @Inject constructor( .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) .build() - val learnerIntroIntent = IntroActivity.createIntroActivity(activity, params, profileId) + val learnerIntroIntent = + IntroActivity.createIntroActivity(activity, params, profileId) val adminIntroIntent = AdminIntroActivity.createAdminIntroActivityIntent( activity, diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt index 96d7a97acc6..2be258c8ce3 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt @@ -26,6 +26,7 @@ import org.oppia.android.util.platformparameter.PlatformParameterValue import org.oppia.android.util.profile.toProfileIdPreservingZero import javax.inject.Inject +// TODO(#5817): Remove when v2 onboarding flow has stabilized. /** The presenter for [AdminPinActivity]. */ @ActivityScope class AdminPinActivityPresenter @Inject constructor( diff --git a/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivity.kt b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivity.kt new file mode 100644 index 00000000000..11f03d2284f --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivity.kt @@ -0,0 +1,38 @@ +package org.oppia.android.app.profile + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableSystemLocalizedAppCompatActivity +import org.oppia.android.app.model.LegacyProfileId +import org.oppia.android.app.model.ScreenName.CREATE_ADMIN_PIN_ACTIVITY +import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId +import javax.inject.Inject + +/** Activity for displaying the Admin PIN setup screen. */ +class CreateAdminPinActivity : InjectableSystemLocalizedAppCompatActivity() { + + @Inject + lateinit var createAdminPinActivityPresenter: CreateAdminPinActivityPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + (activityComponent as ActivityComponentImpl).inject(this) + super.onCreate(savedInstanceState) + + val profileId = intent.extractCurrentUserProfileId() + createAdminPinActivityPresenter.handleOnCreate(profileId) + } + + companion object { + /** Returns a new [Intent] to open a [CreateAdminPinActivity] with the specified params. */ + fun createAdminPinActivityIntent(context: Context, profileId: LegacyProfileId): Intent { + return Intent(context, CreateAdminPinActivity::class.java).apply { + decorateWithScreenName(CREATE_ADMIN_PIN_ACTIVITY) + decorateWithUserProfileId(profileId) + } + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivityPresenter.kt new file mode 100644 index 00000000000..dfa3784276c --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivityPresenter.kt @@ -0,0 +1,44 @@ +package org.oppia.android.app.profile + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.model.LegacyProfileId +import org.oppia.android.app.ui.R +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId +import javax.inject.Inject + +/** The presenter for [CreateAdminPinActivity]. */ +@ActivityScope +class CreateAdminPinActivityPresenter @Inject constructor( + private val activity: AppCompatActivity +) { + private companion object { + private const val TAG_CREATE_ADMIN_PIN_FRAGMENT = "TAG_CREATE_ADMIN_PIN_FRAGMENT" + } + + /** Creates the view for [CreateAdminPinActivity]. */ + fun handleOnCreate(profileId: LegacyProfileId) { + activity.setContentView(R.layout.create_admin_pin_activity) + + if (getPinSetupFragment() == null) { + val createAdminPinFragment = CreateAdminPinFragment().apply { + arguments = Bundle().also { it.decorateWithUserProfileId(profileId) } + } + + activity.supportFragmentManager.beginTransaction() + .add( + R.id.pin_setup_fragment_placeholder, + createAdminPinFragment, + TAG_CREATE_ADMIN_PIN_FRAGMENT + ) + .commitNow() + } + } + + private fun getPinSetupFragment(): CreateAdminPinFragment? { + return activity.supportFragmentManager.findFragmentByTag( + TAG_CREATE_ADMIN_PIN_FRAGMENT + ) as? CreateAdminPinFragment + } +} diff --git a/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragment.kt b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragment.kt new file mode 100644 index 00000000000..1679dbccfac --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragment.kt @@ -0,0 +1,35 @@ +package org.oppia.android.app.profile + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableFragment +import javax.inject.Inject + +/** Fragment that contains the create admin PIN screen. */ +class CreateAdminPinFragment : InjectableFragment() { + + @Inject + lateinit var createAdminPinFragmentPresenter: CreateAdminPinFragmentPresenter + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return createAdminPinFragmentPresenter.handleCreateView(inflater, container, savedInstanceState) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + createAdminPinFragmentPresenter.handleSaveInstanceState(outState) + } +} diff --git a/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragmentPresenter.kt new file mode 100644 index 00000000000..1368c71d2f0 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragmentPresenter.kt @@ -0,0 +1,475 @@ +package org.oppia.android.app.profile + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.CreateAdminPinUiState +import org.oppia.android.app.model.ProfileChooserActivityParams +import org.oppia.android.app.onboarding.PROFILE_CHOOSER_PARAMS_KEY +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.ui.R +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.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId +import org.oppia.android.util.profile.toLegacyProfileId +import org.oppia.android.util.profile.toProfileIdPreservingZero +import javax.inject.Inject + +/** The presenter for [CreateAdminPinFragment]. */ +@FragmentScope +class CreateAdminPinFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler, + private val profileManagementController: ProfileManagementController +) { + private var uiState by mutableStateOf(CreateAdminPinUiState.getDefaultInstance()) + + /** Creates and returns the view for the [CreateAdminPinFragment]. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val composeView = inflater.inflate( + R.layout.create_admin_pin_fragment, container, /* attachToRoot= */ false + ) as ComposeView + createComposeView(composeView, savedInstanceState) + return composeView + } + + /** Saves the current [CreateAdminPinUiState] into [outState] for recreation. */ + fun handleSaveInstanceState(outState: Bundle) { + outState.putByteArray(UI_STATE_SAVED_INSTANCE_STATE_KEY, uiState.toByteArray()) + } + + private fun createComposeView(composeView: ComposeView, savedInstanceState: Bundle?) { + uiState = savedInstanceState + ?.getByteArray(UI_STATE_SAVED_INSTANCE_STATE_KEY) + ?.let { CreateAdminPinUiState.parseFrom(it) } + ?: CreateAdminPinUiState.getDefaultInstance() + + composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme { + CreateAdminPinScreen( + uiState = uiState, + onPinChange = ::onPinChanged, + onConfirmPinChange = ::onConfirmPinChanged, + onBackClick = ::onBackClicked, + onSubmit = ::onSubmit + ) + } + } + } + } + + @OptIn(ExperimentalComposeUiApi::class) + @Composable + private fun CreateAdminPinScreen( + uiState: CreateAdminPinUiState, + onPinChange: (String) -> Unit, + onConfirmPinChange: (String) -> Unit, + onBackClick: () -> Unit, + onSubmit: () -> Unit + ) { + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val orientation = LocalConfiguration.current.orientation + val stepCountIsVisible = orientation == Configuration.ORIENTATION_PORTRAIT + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + CreateAdminPinHeader() + + CreateAdminPinMessage() + + CreateAdminPinInputField( + value = uiState.pin, + onValueChange = onPinChange, + label = resourceHandler + .getStringInLocaleWithWrapping(R.string.create_admin_pin_activity_enter_pin_label), + error = computePinError(uiState.pin), + focusManager = focusManager, + imeAction = ImeAction.Next, + focusRequester = focusRequester + ) + + CreateAdminPinInputField( + value = uiState.confirmPin, + onValueChange = onConfirmPinChange, + label = resourceHandler + .getStringInLocaleWithWrapping(R.string.create_admin_pin_activity_confirm_pin_label), + error = computeConfirmPinError(uiState.pin, uiState.confirmPin), + focusManager = focusManager, + imeAction = ImeAction.Done, + onDone = onSubmit + ) + + CreateAdminPinErrorText( + showError = uiState.showError, + errorMessage = uiState.errorMessage + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (stepCountIsVisible) { + CreateAdminPinStepCountText() + Spacer(modifier = Modifier.height(8.dp)) + } + + CreateAdminPinNavigationButtons( + onBackClick = onBackClick, + onContinueClick = onSubmit, + isContinueEnabled = + validatePins(uiState.pin, uiState.confirmPin) is PinValidationResult.Valid + ) + } + } + + @Composable + private fun CreateAdminPinHeader() { + Text( + text = resourceHandler.getStringInLocaleWithWrapping( + R.string.create_admin_pin_activity_header + ), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = colorResource(R.color.component_color_shared_primary_text_color), + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + @Composable + private fun CreateAdminPinMessage() { + Text( + text = resourceHandler.getStringInLocaleWithWrapping( + R.string.create_admin_pin_activity_message + ), + fontSize = 14.sp, + textAlign = TextAlign.Center, + color = colorResource(R.color.component_color_shared_primary_text_color), + modifier = Modifier.padding(bottom = 24.dp) + ) + } + + @Composable + private fun CreateAdminPinInputField( + value: String, + onValueChange: (String) -> Unit, + label: String, + error: String, + focusManager: FocusManager, + imeAction: ImeAction, + focusRequester: FocusRequester? = null, + onDone: (() -> Unit)? = null + ) { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(text = label) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (error.isEmpty()) 16.dp else 4.dp) + .then( + if (focusRequester != null) Modifier.focusRequester(focusRequester) + else Modifier + ), + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = imeAction + ), + keyboardActions = KeyboardActions( + onNext = if (imeAction == ImeAction.Next) { + { focusManager.moveFocus(FocusDirection.Down) } + } else null, + onDone = if (imeAction == ImeAction.Done) { + { + focusManager.clearFocus() + onDone?.invoke() + } + } else null + ), + visualTransformation = PasswordVisualTransformation(), + isError = error.isNotEmpty(), + textStyle = LocalTextStyle.current.copy(fontSize = 16.sp), + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = colorResource(R.color.component_color_edittext_stroke_color), + focusedBorderColor = colorResource(R.color.component_color_shared_pin_focused_color), + cursorColor = colorResource(R.color.component_color_shared_pin_cursor_color), + backgroundColor = + colorResource(R.color.component_color_shared_transparent_background_color), + textColor = colorResource(R.color.component_color_shared_primary_text_color), + unfocusedLabelColor = colorResource(R.color.component_color_shared_primary_text_color), + focusedLabelColor = colorResource(R.color.component_color_shared_primary_text_color) + ) + ) + if (error.isNotEmpty()) { + Text( + text = error, + color = colorResource(R.color.component_color_shared_error_color), + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 12.dp, start = 16.dp) + ) + } + } + } + + @Composable + private fun CreateAdminPinStepCountText() { + Text( + text = resourceHandler.getStringInLocaleWithWrapping( + R.string.onboarding_step_count_five + ), + color = colorResource(R.color.component_color_onboarding_shared_green_text_color), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + @Composable + private fun CreateAdminPinNavigationButtons( + onBackClick: () -> Unit, + onContinueClick: () -> Unit, + isContinueEnabled: Boolean + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onBackClick) { + Text( + text = resourceHandler.getStringInLocaleWithWrapping(R.string.onboarding_navigation_back), + color = colorResource(R.color.component_color_onboarding_shared_green_text_color), + fontWeight = FontWeight.Bold + ) + } + + Button( + onClick = onContinueClick, + enabled = isContinueEnabled, + modifier = Modifier + .height(48.dp) + .widthIn(min = 140.dp, max = 200.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = colorResource(R.color.component_color_onboarding_shared_green_color), + disabledBackgroundColor = + colorResource(R.color.component_color_shared_item_selection_interaction_disabled_color) + ) + ) { + Text( + text = resourceHandler + .getStringInLocaleWithWrapping(R.string.onboarding_navigation_continue), + color = colorResource(R.color.component_color_onboarding_shared_white_color), + fontWeight = FontWeight.Bold + ) + } + } + } + + @Composable + private fun CreateAdminPinErrorText(showError: Boolean, errorMessage: String) { + if (showError) { + Text( + text = errorMessage, + textAlign = TextAlign.Center, + style = TextStyle( + fontSize = 14.sp, + color = colorResource(id = R.color.component_color_shared_error_color) + ) + ) + } + } + + private fun onPinChanged(newValue: String) { + if (newValue.all { it.isDigit() } && newValue.length <= ADMIN_PIN_LENGTH) { + uiState = uiState.toBuilder() + .setPin(newValue) + .setShowError(if (newValue.isNotEmpty()) false else uiState.showError) + .build() + } + } + + private fun onConfirmPinChanged(newValue: String) { + if (newValue.all { it.isDigit() } && newValue.length <= ADMIN_PIN_LENGTH) { + uiState = uiState.toBuilder() + .setConfirmPin(newValue) + .setShowError(if (newValue.isNotEmpty()) false else uiState.showError) + .build() + } + } + + private fun onBackClicked() { + activity.finish() + } + + private fun onSubmit() { + when (val result = validatePins(uiState.pin, uiState.confirmPin)) { + PinValidationResult.Valid -> { + uiState = uiState.toBuilder().setShowError(false).build() + updatePin(uiState.pin) + } + + is PinValidationResult.Invalid -> { + uiState = uiState.toBuilder() + .setShowError(true) + .setErrorMessage(resourceHandler.getStringInLocaleWithWrapping(result.errorMessageId)) + .build() + } + } + } + + private fun computePinError(pin: String): String { + return when { + pin.isNotEmpty() && pin.length < ADMIN_PIN_LENGTH -> + resourceHandler.getStringInLocaleWithWrapping(pinLengthError) + + else -> "" + } + } + + private fun computeConfirmPinError(pin: String, confirmPin: String): String { + return when { + confirmPin.isNotEmpty() && confirmPin != pin -> + resourceHandler.getStringInLocaleWithWrapping(pinMismatchError) + + else -> "" + } + } + + private fun validatePins(pin: String, confirmPin: String): PinValidationResult { + val isValidPin = pin.length == ADMIN_PIN_LENGTH && pin.all(Char::isDigit) + val isValidConfirmPin = confirmPin.length == ADMIN_PIN_LENGTH && confirmPin.all(Char::isDigit) + + return when { + isValidPin && isValidConfirmPin && pin == confirmPin -> PinValidationResult.Valid + pin.isEmpty() -> PinValidationResult.Invalid(pinBlankError) + !isValidPin || !isValidConfirmPin -> PinValidationResult.Invalid(pinLengthError) + else -> PinValidationResult.Invalid(pinMismatchError) + } + } + + private fun updatePin(pin: String) { + val profileId = + checkNotNull(fragment.arguments?.extractCurrentUserProfileId()?.toProfileIdPreservingZero()) { + "Expected profileId to be included in the arguments for CreateAdminPinFragment." + } + + profileManagementController.updatePin(profileId, pin).toLiveData().observe(fragment) { + if (it is AsyncResult.Success) { + val intent = ProfileChooserActivity.createProfileChooserActivity(activity).also { intent -> + intent.decorateWithUserProfileId(profileId.toLegacyProfileId()) + intent.putProtoExtra( + PROFILE_CHOOSER_PARAMS_KEY, + ProfileChooserActivityParams.newBuilder() + .setParentScreen(ProfileChooserActivityParams.ParentScreen.CREATE_ADMIN_PIN_SCREEN) + .build() + ) + } + fragment.startActivity(intent) + fragment.activity?.finishAffinity() + } + } + } + + companion object { + private const val ADMIN_PIN_LENGTH = 5 + private const val UI_STATE_SAVED_INSTANCE_STATE_KEY = "CreateAdminPinFragmentPresenter.ui_state" + + private val pinMismatchError = R.string.create_admin_pin_activity_mismatch_error + + private val pinLengthError = R.string.create_admin_pin_activity_length_error + + private val pinBlankError = R.string.create_admin_pin_activity_blank_error + } + + /** Sealed class for the PIN validation result. */ + private sealed class PinValidationResult { + /** Indicates that both PIN fields are non-empty, correctly sized, and match each other. */ + object Valid : PinValidationResult() + + /** + * Indicates that validation failed. + * + * @property errorMessageId the string resource ID of the error message to display to the user. + */ + data class Invalid(@StringRes val errorMessageId: Int) : PinValidationResult() + } +} diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt index 646c1e24293..1a664cae4c2 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt @@ -97,7 +97,7 @@ class ProfileChooserFragmentPresenter @Inject constructor( adminProfileId: LegacyProfileId, parentScreen: ParentScreen ): View? { - if (parentScreen == ParentScreen.ADMIN_INTRO_SCREEN) { + if (parentScreen == ParentScreen.CREATE_ADMIN_PIN_SCREEN) { // The admin onboarding ends here in order to prevent the admin from seeing the onboarding // flow again if they exit the app at this point. profileManagementController.markProfileOnboardingEnded( diff --git a/app/src/main/res/layout/create_admin_pin_activity.xml b/app/src/main/res/layout/create_admin_pin_activity.xml new file mode 100644 index 00000000000..8f33a7bc25d --- /dev/null +++ b/app/src/main/res/layout/create_admin_pin_activity.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/create_admin_pin_fragment.xml b/app/src/main/res/layout/create_admin_pin_fragment.xml new file mode 100644 index 00000000000..d337ed19459 --- /dev/null +++ b/app/src/main/res/layout/create_admin_pin_fragment.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/values-night/color_palette.xml b/app/src/main/res/values-night/color_palette.xml index 4343e97520f..97a4514da77 100644 --- a/app/src/main/res/values-night/color_palette.xml +++ b/app/src/main/res/values-night/color_palette.xml @@ -236,7 +236,8 @@ @color/color_def_black_25 @color/color_def_dark_green - @color/color_def_accessible_grey + @color/color_def_white + @color/color_def_white @color/color_def_jade @color/color_def_oppia_green @color/color_def_black @@ -263,4 +264,6 @@ @color/color_def_highlight_blue_darker @color/color_def_white + + @color/color_def_white diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml index bb7182c39ee..3fc9871b11d 100644 --- a/app/src/main/res/values/color_palette.xml +++ b/app/src/main/res/values/color_palette.xml @@ -278,6 +278,7 @@ @color/color_def_oppia_green @color/color_def_accessible_grey + @color/color_def_oppia_green @color/color_def_jade @color/color_def_oppia_brown @color/color_def_light_orange @@ -309,4 +310,6 @@ @color/color_def_light_jade @color/color_def_accessible_grey @color/color_def_dark_silver + + @color/color_def_black diff --git a/app/src/main/res/values/component_colors.xml b/app/src/main/res/values/component_colors.xml index f8629ce4a80..4fad521e16f 100644 --- a/app/src/main/res/values/component_colors.xml +++ b/app/src/main/res/values/component_colors.xml @@ -319,6 +319,7 @@ @color/color_palette_white_text_color @color/color_palette_onboarding_primary_color + @color/color_palette_onboarding_secondary_text_color @color/color_palette_onboarding_black_color @color/color_palette_onboarding_primary_text_color @color/color_palette_onboarding_profile_type_background_color @@ -357,4 +358,8 @@ @color/color_palette_primary_color @color/color_palette_profile_login_unfocused_state_color @color/color_palette_white_text_color + + + @color/color_palette_pin_stroke_color + @color/color_palette_pin_stroke_color diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09c0e7724b0..ae933f16c1e 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -746,4 +746,14 @@ As the admin of the app, you can Manage profile settings for all learners on this device Add new learners to this device (up to 10) + + + Create Admin PIN + Set up a unique PIN to protect the admin controls + Administrators are able to control content downloads and manage all profiles on this device + Enter a 5-Digit PIN + Confirm the 5-Digit PIN + Type a 5-Digit PIN + Your PIN should be 5 digits long + Please make sure that both PINs match diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/AdminIntroFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/AdminIntroFragmentTest.kt index c604657545d..bb2e1f638f5 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/AdminIntroFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/AdminIntroFragmentTest.kt @@ -35,14 +35,12 @@ 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.LegacyProfileId -import org.oppia.android.app.model.ProfileChooserActivityParams 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.profile.CreateAdminPinActivity import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.test.R import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule -import org.oppia.android.app.utility.EspressoTestsMatchers.hasProtoExtra import org.oppia.android.data.backends.gae.NetworkConfigProdModule import org.oppia.android.data.backends.gae.RetrofitModule import org.oppia.android.data.backends.gae.RetrofitServiceModule @@ -195,7 +193,6 @@ class AdminIntroFragmentTest { @Test fun testIntroFragment_onBackButtonClicked_currentScreenIsDestroyed() { launch(AdminIntroActivity::class.java).use { scenario -> - scenario.onActivity { activity -> composeRule.onNodeWithText(context.getString(R.string.onboarding_navigation_back)) .performClick() @@ -207,21 +204,14 @@ class AdminIntroFragmentTest { } } - // This is a placeholder test that should fail when the PIN creation screen has been implemented. @Test - fun testIntroFragment_continueButtonClicked_launchesProfileChooserActivity() { + fun testIntroFragment_continueButtonClicked_launchesCreateAdminPinActivity() { launchAdminIntroActivity().use { composeRule.onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) .performClick() testCoroutineDispatchers.runCurrent() - - val expectedParams = ProfileChooserActivityParams.newBuilder() - .setParentScreen(ProfileChooserActivityParams.ParentScreen.ADMIN_INTRO_SCREEN) - .build() - - intended(hasComponent(ProfileChooserActivity::class.java.name)) - intended(hasProtoExtra(PROFILE_CHOOSER_PARAMS_KEY, expectedParams)) + intended(hasComponent(CreateAdminPinActivity::class.java.name)) intended(hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR)) } } 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 140370f362e..6d78582d0f5 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 @@ -34,8 +34,8 @@ 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.allOf import org.hamcrest.Matcher -import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not import org.junit.After import org.junit.Before @@ -95,7 +95,6 @@ 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 @@ -185,7 +184,7 @@ class CreateProfileFragmentTest { } @Test - fun testFragment_stepCountText_isDisplayed() { + fun testFragment_learnerOnboardingFlow_stepCountThreeText_isDisplayed() { launchNewLearnerProfileActivity().use { onView(withId(R.id.onboarding_steps_count)) .check( @@ -204,7 +203,26 @@ class CreateProfileFragmentTest { } @Test - fun testFragment_learner_clickContinueButton_filledNickname_launchesLearnerIntroScreen() { + fun testFragment_supervisorOnboardingFlow_stepCountThreeText_isDisplayed() { + launchNewLearnerProfileActivity(ProfileType.SUPERVISOR).use { + onView(withId(R.id.onboarding_steps_count)) + .check( + matches( + allOf( + isDisplayed(), + withText( + context.getString( + R.string.onboarding_step_count_three + ) + ) + ) + ) + ) + } + } + + @Test + fun testLearnerOnboarding_continueClicked_filledNickname_launchesLearnerIntroScreen() { launchNewLearnerProfileActivity().use { onView(withId(R.id.create_profile_nickname_edittext)) .perform( @@ -256,7 +274,6 @@ class CreateProfileFragmentTest { } @Test - @DisableAccessibilityChecks fun testFragment_continueButtonClicked_filledNickname_doesNotShowErrorText() { launchNewLearnerProfileActivity().use { onView(withId(R.id.create_profile_nickname_edittext)) diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt index fd7de419c60..bd8b2316c5f 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt @@ -149,7 +149,7 @@ class OptionsFragmentTest { @After fun tearDown() { - testCoroutineDispatchers.registerIdlingResource() + testCoroutineDispatchers.unregisterIdlingResource() Intents.release() } diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/CreateAdminPinActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/CreateAdminPinActivityTest.kt new file mode 100644 index 00000000000..1dba0f9e0f9 --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/CreateAdminPinActivityTest.kt @@ -0,0 +1,238 @@ +package org.oppia.android.app.profile + +import android.app.Application +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.test.junit4.createEmptyComposeRule +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.common.truth.Truth.assertThat +import dagger.Component +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +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.LegacyProfileId +import org.oppia.android.app.model.ScreenName +import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule +import org.oppia.android.app.shim.ViewBindingShimModule +import org.oppia.android.app.test.R +import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.data.backends.gae.NetworkConfigProdModule +import org.oppia.android.data.backends.gae.RetrofitModule +import org.oppia.android.data.backends.gae.RetrofitServiceModule +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.PlatformParameterSingletonModule +import org.oppia.android.domain.question.QuestionModule +import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.TestLogReportingModule +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.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.CurrentAppScreenNameIntentDecorator.extractCurrentAppScreenName +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.GlideImageLoaderModule +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 [CreateAdminPinActivity]. */ +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + application = CreateAdminPinActivityTest.TestApplication::class, + qualifiers = "port-xxhdpi" +) +class CreateAdminPinActivityTest { + @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() + @get:Rule val oppiaTestRule = OppiaTestRule() + @get:Rule val composeRule = createEmptyComposeRule() + @Inject lateinit var context: Context + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Before + fun setUp() { + Intents.init() + setUpTestApplicationComponent() + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + fun testActivity_createIntent_verifyScreenNameInIntent() { + val screenName = createPinSetupActivityIntent(context).extractCurrentAppScreenName() + assertThat(screenName).isEqualTo(ScreenName.CREATE_ADMIN_PIN_ACTIVITY) + } + + @Test + fun testActivity_hasCorrectActivityLabel() { + ActivityScenario.launch(createPinSetupActivityIntent(context)) + .use { scenario -> + testCoroutineDispatchers.runCurrent() + scenario?.onActivity { activity -> + val title = activity.title + assertThat(title).isEqualTo( + context.getString(R.string.create_admin_pin_activity_title) + ) + } + } + } + + private fun createPinSetupActivityIntent(context: Context): Intent { + val profileId = LegacyProfileId.newBuilder().setInternalId(0).build() + return CreateAdminPinActivity.createAdminPinActivityIntent(context, profileId) + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + AccessibilityTestModule::class, + ActivityRecreatorTestModule::class, + ActivityRouterModule::class, + AlgebraicExpressionInputModule::class, + ApplicationLifecycleModule::class, + ApplicationModule::class, + ApplicationStartupListenerModule::class, + AssetModule::class, + CachingTestModule::class, + ContinueModule::class, + CpuPerformanceSnapshotterModule::class, + DeveloperOptionsModule::class, + DeveloperOptionsStarterModule::class, + DragDropSortInputModule::class, + ExpirationMetaDataRetrieverModule::class, + ExplorationProgressModule::class, + ExplorationStorageModule::class, + FakeOppiaClockModule::class, + FirebaseLogUploaderModule::class, + FractionInputModule::class, + GcsResourceModule::class, + GlideImageLoaderModule::class, + HintsAndSolutionConfigModule::class, + HintsAndSolutionProdModule::class, + HtmlParserEntityTypeModule::class, + ImageClickInputModule::class, + ImageParsingModule::class, + InteractionsModule::class, + ItemSelectionInputModule::class, + LocaleProdModule::class, + LogReportWorkerModule::class, + LogStorageModule::class, + LoggerModule::class, + LoggingIdentifierModule::class, + MathEquationInputModule::class, + MetricLogSchedulerModule::class, + MultipleChoiceInputModule::class, + NetworkConfigProdModule::class, + NetworkConnectionDebugUtilModule::class, + NetworkConnectionUtilDebugModule::class, + NumberWithUnitsRuleModule::class, + NumericExpressionInputModule::class, + NumericInputRuleModule::class, + PlatformParameterSingletonModule::class, + QuestionModule::class, + RatioInputModule::class, + RetrofitModule::class, + RetrofitServiceModule::class, + RobolectricModule::class, + SplitScreenInteractionModule::class, + SyncStatusModule::class, + TestAuthenticationModule::class, + TestDispatcherModule::class, + TestLogReportingModule::class, + TestPlatformParameterModule::class, + TestingBuildFlavorModule::class, + TextInputRuleModule::class, + ViewBindingShimModule::class, + WorkManagerConfigurationModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder : ApplicationComponent.Builder + + fun inject(createAdminPinActivityTest: CreateAdminPinActivityTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerCreateAdminPinActivityTest_TestApplicationComponent.builder() + .setApplication(this) + .build() as TestApplicationComponent + } + + fun inject(createAdminPinActivityTest: CreateAdminPinActivityTest) { + component.inject(createAdminPinActivityTest) + } + + 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/profile/CreateAdminPinFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/CreateAdminPinFragmentTest.kt new file mode 100644 index 00000000000..efab6cf4c1f --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/CreateAdminPinFragmentTest.kt @@ -0,0 +1,646 @@ +package org.oppia.android.app.profile + +import android.app.Application +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertValueEquals +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ActivityScenario.launch +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.Component +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +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.player.state.itemviewmodel.SplitScreenInteractionModule +import org.oppia.android.app.shim.ViewBindingShimModule +import org.oppia.android.app.test.R +import org.oppia.android.app.translation.testing.ActivityRecreatorTestModule +import org.oppia.android.data.backends.gae.NetworkConfigProdModule +import org.oppia.android.data.backends.gae.RetrofitModule +import org.oppia.android.data.backends.gae.RetrofitServiceModule +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.PlatformParameterSingletonModule +import org.oppia.android.domain.profile.ProfileManagementController +import org.oppia.android.domain.question.QuestionModule +import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule +import org.oppia.android.testing.OppiaTestRule +import org.oppia.android.testing.TestLogReportingModule +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 +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.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.GlideImageLoaderModule +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 [CreateAdminPinFragment]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + application = CreateAdminPinFragmentTest.TestApplication::class, + qualifiers = "port-xxhdpi" +) +class CreateAdminPinFragmentTest { + @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() + @get:Rule val oppiaTestRule = OppiaTestRule() + @get:Rule val composeRule = createEmptyComposeRule() + @Inject lateinit var context: Context + @Inject lateinit var profileTestHelper: ProfileTestHelper + @Inject lateinit var profileManagementController: ProfileManagementController + @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Before + fun setUp() { + Intents.init() + setUpTestApplicationComponent() + } + + @After + fun tearDown() { + Intents.release() + TestPlatformParameterModule.reset() + } + + @Test + fun testFragment_onLaunch_allTextViewsHaveCorrectContent() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_header)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_message)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.onboarding_step_count_five)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_back)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsDisplayed() + } + } + + @Test + @Config(qualifiers = "+land") + fun testFragment_landscapeMode_onLaunch_allTextViewsHaveCorrectContent() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_header)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_message)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.onboarding_step_count_five)) + .assertDoesNotExist() + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_back)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsDisplayed() + } + } + + @Test + fun testFragment_clickContinue_withEmptyPin_showsBlankPinError_continueButtonIsDisabled() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .performClick() + + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_blank_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_clickContinue_withFilledPinAndEmptyConfirmPin_showsMismatchError() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performTextInput("12345") + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .performClick() + + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_mismatch_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_clickContinue_withMismatchedPins_showsMismatchError_continueButtonIsDisabled() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("12345") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("54321") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_mismatch_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_inputThreeDigitPin_showsPinLengthError_continueButtonIsDisabled() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("123") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_length_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_inputShortPin_inputSameShortConfirmPin_showsErrorForPin_continueIsDisabled() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("123") + + // Verify that the length error is shown for the PIN field. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_length_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("123") + + // Verify that the mismatch error is shown for the confirm PIN field. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_mismatch_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_inputShortPin_inputDiffShortConfirmPin_showsErrorForBoth_continueIsDisabled() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("123") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_length_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("456") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_mismatch_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_validPin_shortConfirm_showsMismatchError_continueDisabled() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("12345") + + // Enter a too-short confirm PIN. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("12") + + composeRule.waitForIdle() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_mismatch_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + } + } + + @Test + fun testFragment_enterMatchingConfirmPin_afterPinLengthError_enablesContinueButton() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Start with a short PIN to trigger length error. + val enterPinNode = context.getString(R.string.create_admin_pin_activity_enter_pin_label) + composeRule + .onNodeWithText(enterPinNode) + .performClick() + .performTextInput("12") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_length_error)) + .assertIsDisplayed() + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsNotEnabled() + + // Continue typing more digits. + composeRule.onNodeWithText(enterPinNode) + .performClick() + .performTextInput("345") + + composeRule.waitForIdle() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_length_error)) + .assertDoesNotExist() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("12345") + + composeRule.waitForIdle() + + // Enter matching confirm PIN; continue should be enabled now. + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .assertIsEnabled() + } + } + + @Test + fun testFragment_imeActionDone_withMatchingValidPins_navigatesToProfileChooser() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("12345") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("12345") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performImeAction() + + intended(hasComponent(ProfileChooserActivity::class.java.name)) + } + } + + @Test + fun testFragment_clickContinue_withMatchingValidPins_navigatesToProfileChooser() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("12345") + + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("12345") + + composeRule + .onNodeWithText(context.getString(R.string.onboarding_navigation_continue)) + .performClick() + + intended(hasComponent(ProfileChooserActivity::class.java.name)) + } + } + + @Test + fun testFragment_onBackButtonClicked_currentScreenIsDestroyed() { + launch(CreateAdminPinActivity::class.java).use { scenario -> + testCoroutineDispatchers.runCurrent() + + scenario.onActivity { activity -> + composeRule.onNodeWithText(context.getString(R.string.onboarding_navigation_back)) + .performClick() + + assertThat(activity.isFinishing).isTrue() + } + } + } + + @Test + fun testFragment_inputPin_onlyAcceptsDigits() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Try to input non-digit characters. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("abc12def") + + // Should only accept the digits. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .assertValueEquals("12") + } + } + + @Test + fun testFragment_inputConfirmPin_onlyAcceptsDigits() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Try to input non-digit characters. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("abc12def") + + // Should only accept the digits. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .assertValueEquals("12") + } + } + + @Test + fun testFragment_inputPin_limitsToFiveDigits() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Try to input more than 5 digits. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .performClick() + .performTextInput("123456789") + + testCoroutineDispatchers.runCurrent() + + // Should only accept the first 5 digits. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_enter_pin_label)) + .assertValueEquals("12345") + } + } + + @Test + fun testFragment_inputConfirmPin_limitsToFiveDigits() { + launch(CreateAdminPinActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + + // Try to input more than 5 digits. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .performClick() + .performTextInput("123456789") + + // Should only accept the first 5 digits. + composeRule + .onNodeWithText(context.getString(R.string.create_admin_pin_activity_confirm_pin_label)) + .assertValueEquals("12345") + } + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + AccessibilityTestModule::class, + ActivityRecreatorTestModule::class, + ActivityRouterModule::class, + AlgebraicExpressionInputModule::class, + ApplicationLifecycleModule::class, + ApplicationModule::class, + ApplicationStartupListenerModule::class, + AssetModule::class, + CachingTestModule::class, + ContinueModule::class, + CpuPerformanceSnapshotterModule::class, + DeveloperOptionsModule::class, + DeveloperOptionsStarterModule::class, + DragDropSortInputModule::class, + ExpirationMetaDataRetrieverModule::class, + ExplorationProgressModule::class, + ExplorationStorageModule::class, + FakeOppiaClockModule::class, + FirebaseLogUploaderModule::class, + FractionInputModule::class, + GcsResourceModule::class, + GlideImageLoaderModule::class, + HintsAndSolutionConfigModule::class, + HintsAndSolutionProdModule::class, + HtmlParserEntityTypeModule::class, + ImageClickInputModule::class, + ImageParsingModule::class, + InteractionsModule::class, + ItemSelectionInputModule::class, + LocaleProdModule::class, + LogReportWorkerModule::class, + LogStorageModule::class, + LoggerModule::class, + LoggingIdentifierModule::class, + MathEquationInputModule::class, + MetricLogSchedulerModule::class, + MultipleChoiceInputModule::class, + NetworkConfigProdModule::class, + NetworkConnectionDebugUtilModule::class, + NetworkConnectionUtilDebugModule::class, + NumberWithUnitsRuleModule::class, + NumericExpressionInputModule::class, + NumericInputRuleModule::class, + PlatformParameterSingletonModule::class, + QuestionModule::class, + RatioInputModule::class, + RetrofitModule::class, + RetrofitServiceModule::class, + RobolectricModule::class, + SplitScreenInteractionModule::class, + SyncStatusModule::class, + TestAuthenticationModule::class, + TestDispatcherModule::class, + TestLogReportingModule::class, + TestPlatformParameterModule::class, + TestingBuildFlavorModule::class, + TextInputRuleModule::class, + ViewBindingShimModule::class, + WorkManagerConfigurationModule::class + ] + ) + interface TestApplicationComponent : ApplicationComponent { + @Component.Builder + interface Builder : ApplicationComponent.Builder { + override fun build(): TestApplicationComponent + } + + fun inject(createAdminPinFragmentTest: CreateAdminPinFragmentTest) + } + + class TestApplication : Application(), ActivityComponentFactory, ApplicationInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerCreateAdminPinFragmentTest_TestApplicationComponent.builder() + .setApplication(this) + .build() as TestApplicationComponent + } + + fun inject(createAdminPinFragmentTest: CreateAdminPinFragmentTest) { + component.inject(createAdminPinFragmentTest) + } + + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() + } + + override fun getApplicationInjector(): ApplicationInjector = component + } +} diff --git a/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt b/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt index 2b147e7464e..c7b266f7c98 100644 --- a/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt @@ -126,7 +126,7 @@ class ProfileChooserFragmentLocalTest { TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) setUpTestApplicationComponent() launch( - createProfileChooserActivityIntent(ParentScreen.ADMIN_INTRO_SCREEN) + createProfileChooserActivityIntent(ParentScreen.CREATE_ADMIN_PIN_SCREEN) ).use { testCoroutineDispatchers.runCurrent() val event = fakeAnalyticsEventLogger.getMostRecentEvent() @@ -172,7 +172,7 @@ class ProfileChooserFragmentLocalTest { profileTestHelper.addOnlyAdminProfileWithoutPin() launch( - createProfileChooserActivityIntent(ParentScreen.ADMIN_INTRO_SCREEN) + createProfileChooserActivityIntent(ParentScreen.CREATE_ADMIN_PIN_SCREEN) ).use { testCoroutineDispatchers.runCurrent() diff --git a/model/src/main/proto/arguments.proto b/model/src/main/proto/arguments.proto index da053ba56b8..d752c9f823f 100644 --- a/model/src/main/proto/arguments.proto +++ b/model/src/main/proto/arguments.proto @@ -997,11 +997,11 @@ message ProfileChooserActivityParams { // Indicates that the originating screen isn't actually known. PARENT_SCREEN_UNSPECIFIED = 0; - // Corresponds to the story viewer screen. + // Corresponds to the splash screen. SPLASH_SCREEN = 1; - // Corresponds to the admin intro screen. - ADMIN_INTRO_SCREEN = 2; + // Corresponds to the create admin PIN screen. + CREATE_ADMIN_PIN_SCREEN = 2; // Corresponds to the exit profile dialog. EXIT_PROFILE_SCREEN = 3; diff --git a/model/src/main/proto/screens.proto b/model/src/main/proto/screens.proto index 3b7193fbb73..e4d049afbd3 100644 --- a/model/src/main/proto/screens.proto +++ b/model/src/main/proto/screens.proto @@ -185,6 +185,9 @@ enum ScreenName { // Screen name value for the scenario when the study guide activity is visible to the user. STUDY_GUIDE_ACTIVITY = 58; + + // Screen name value for the scenario when the create admin PIN screen is visible to the user. + CREATE_ADMIN_PIN_ACTIVITY = 59; } // Defines the current visible UI screen of the application. @@ -192,3 +195,18 @@ message CurrentAppScreen { // Screen name value depicting the name of the currently visible UI screen. ScreenName screen_name = 1; } + +// Represents the UI state for the create admin PIN screen. +message CreateAdminPinUiState { + // The PIN currently entered by the user. + string pin = 1; + + // The confirmation PIN currently entered by the user. + string confirm_pin = 2; + + // Whether a submit-time validation error should be displayed. + bool show_error = 3; + + // The submit-time error message to display when show_error is true. + string error_message = 4; +} diff --git a/scripts/assets/android_lint_exemptions.textproto b/scripts/assets/android_lint_exemptions.textproto index ff4327e0d05..5c324da1fbc 100644 --- a/scripts/assets/android_lint_exemptions.textproto +++ b/scripts/assets/android_lint_exemptions.textproto @@ -199,6 +199,10 @@ android_lint_exemption: { exempted_file_path: "app/src/main/res/layout-land/audio_language_selection_fragment.xml" lint_issue_id: BACK_BUTTON } +android_lint_exemption: { + exempted_file_path: "app/src/main/res/layout/create_admin_pin_fragment.xml" + lint_issue_id: OVERDRAW +} android_lint_exemption: { exempted_file_path: "app/src/main/res/layout-land/create_profile_fragment.xml" lint_issue_id: AUTOFILL diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index 894b9757df2..0ee4f6b8639 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -2040,6 +2040,14 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/AdminSettingsViewModel.kt" test_file_not_required: true } +test_file_exemption { +exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/CreateAdminPinActivityPresenter.kt" +test_file_not_required: true +} +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/CreateAdminPinFragmentPresenter.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/DataResetConfirmationDialog.kt" test_file_not_required: true diff --git a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt index 4bd2fa892dd..2509e48f223 100644 --- a/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt +++ b/utility/src/main/java/org/oppia/android/util/logging/EventBundleCreator.kt @@ -908,6 +908,7 @@ class EventBundleCreator @Inject constructor( ScreenName.PROFILE_LOGIN_ACTIVITY -> "profile_login_activity" ScreenName.ADMIN_INTRO_ACTIVITY -> "admin_intro_activity" ScreenName.STUDY_GUIDE_ACTIVITY -> "study_guide_activity" + ScreenName.CREATE_ADMIN_PIN_ACTIVITY -> "create_admin_pin_activity" } private fun AppLanguageSelection.toAnalyticsText(): String {