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 {