diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 4fa60d192e0..b95d906cbc3 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -443,10 +443,24 @@ jobs: run: | bazel build --compilation_mode=opt -- //:oppia_alpha_kitkat + # Note that caching only works on non-forks. + - name: Build Oppia alpha Kenya-specific AAB (with caching, non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + env: + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} + run: | + bazel build --compilation_mode=opt --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_alpha_kenya + + - name: Build Oppia alpha Kenya-specific AAB (without caching, or on a fork) + if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} + run: | + bazel build --compilation_mode=opt -- //:oppia_alpha_kenya + - name: Copy Oppia alpha AABs for uploading run: | cp $GITHUB_WORKSPACE/bazel-bin/oppia_alpha.aab /home/runner/work/oppia-android/oppia-android/ cp $GITHUB_WORKSPACE/bazel-bin/oppia_alpha_kitkat.aab /home/runner/work/oppia-android/oppia-android/ + cp $GITHUB_WORKSPACE/bazel-bin/oppia_alpha_kenya.aab /home/runner/work/oppia-android/oppia-android/ - uses: actions/upload-artifact@v2 with: @@ -457,3 +471,8 @@ jobs: with: name: oppia_alpha_kitkat.aab path: /home/runner/work/oppia-android/oppia-android/oppia_alpha_kitkat.aab + + - uses: actions/upload-artifact@v2 + with: + name: oppia_alpha_kenya.aab + path: /home/runner/work/oppia-android/oppia-android/oppia_alpha_kenya.aab diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 807a7ae9555..6f57ec2d58f 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -211,6 +211,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/parser/FractionParsingUiError.kt", "src/main/java/org/oppia/android/app/parser/StringToNumberParser.kt", "src/main/java/org/oppia/android/app/parser/StringToRatioParser.kt", + "src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt", @@ -218,6 +219,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt", + "src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt", "src/main/java/org/oppia/android/app/profile/AddProfileViewModel.kt", "src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt", "src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt", @@ -296,7 +298,6 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/options/OptionsItemViewModel.kt", "src/main/java/org/oppia/android/app/options/OptionsReadingTextSizeViewModel.kt", "src/main/java/org/oppia/android/app/options/ReadingTextSizeSelectionViewModel.kt", - "src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt", "src/main/java/org/oppia/android/app/player/exploration/ExplorationViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContentViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt", @@ -312,7 +313,6 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmitButtonViewModel.kt", - "src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt", "src/main/java/org/oppia/android/app/player/state/StateViewModel.kt", "src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestViewModel.kt", "src/main/java/org/oppia/android/app/profile/AdminAuthViewModel.kt", diff --git a/app/build.gradle b/app/build.gradle index cbcafeda5e8..4709cf0f06f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,7 +98,6 @@ def filesToExclude = [ '**/*AppLanguageLocaleHandlerTest*.kt', '**/*AppLanguageResourceHandlerTest*.kt', '**/*AppLanguageWatcherMixinTest*.kt', - '**/alphakenya/*.kt', ] _excludeSourceFiles(filesToExclude) diff --git a/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaApplicationComponent.kt b/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaApplicationComponent.kt index d6ad08c7266..5a31d7f8f4d 100644 --- a/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaApplicationComponent.kt +++ b/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaApplicationComponent.kt @@ -89,7 +89,7 @@ import javax.inject.Singleton LoggingIdentifierModule::class, ApplicationLifecycleModule::class, NetworkConnectionDebugUtilModule::class, LoggingIdentifierModule::class, SyncStatusModule::class, LogReportingModule::class, NetworkConnectionUtilProdModule::class, - HintsAndSolutionProdModule::class, AlphaKenyaBuildFlavorModule::class + HintsAndSolutionProdModule::class ] ) interface AlphaKenyaApplicationComponent : ApplicationComponent { diff --git a/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaBuildFlavorModule.kt b/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaBuildFlavorModule.kt deleted file mode 100644 index 6947ad845cd..00000000000 --- a/app/src/main/java/org/oppia/android/app/application/alphakenya/AlphaKenyaBuildFlavorModule.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.oppia.android.app.application.alphakenya - -import dagger.Module -import dagger.Provides -import org.oppia.android.app.model.BuildFlavor - -/** - * Module for providing the compile-time [BuildFlavor] of the Kenya-specific alpha build of the app. - */ -@Module -class AlphaKenyaBuildFlavorModule { - @Provides - fun provideAlphaKenyaBuildFlavor(): BuildFlavor = BuildFlavor.ALPHA -} diff --git a/app/src/main/java/org/oppia/android/app/application/alphakenya/BUILD.bazel b/app/src/main/java/org/oppia/android/app/application/alphakenya/BUILD.bazel index 706f199cdfe..732bb23a39a 100644 --- a/app/src/main/java/org/oppia/android/app/application/alphakenya/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/application/alphakenya/BUILD.bazel @@ -10,7 +10,6 @@ kt_android_library( name = "alpha_kenya_application", srcs = [ "AlphaKenyaApplicationComponent.kt", - "AlphaKenyaBuildFlavorModule.kt", "AlphaKenyaOppiaApplication.kt", ], visibility = ["//:oppia_binary_visibility"], diff --git a/app/src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt b/app/src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt index 49972bda253..6a97afa80e3 100644 --- a/app/src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt @@ -33,23 +33,25 @@ class FractionInputInteractionView @JvmOverloads constructor( init { onFocusChangeListener = this + // Assume multi-line for the purpose of properly showing long hints. + setSingleLine(hint != null) hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { - hint = "" - typeface = Typeface.DEFAULT + hideHint() showSoftKeyboard(v, context) } else { - hint = hintText - if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + restoreHint() hideSoftKeyboard(v, context) } override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { - if (event.keyCode == KEYCODE_BACK && event.action == ACTION_UP) - this.clearFocus() + if (event.keyCode == KEYCODE_BACK && event.action == ACTION_UP) { + clearFocus() + restoreHint() + } return super.onKeyPreIme(keyCode, event) } @@ -59,4 +61,16 @@ class FractionInputInteractionView @JvmOverloads constructor( } super.onEditorAction(actionCode) } + + private fun hideHint() { + hint = "" + typeface = Typeface.DEFAULT + setSingleLine(true) + } + + private fun restoreHint() { + hint = hintText + if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + setSingleLine(false) + } } diff --git a/app/src/main/java/org/oppia/android/app/customview/interaction/MathExpressionInteractionsView.kt b/app/src/main/java/org/oppia/android/app/customview/interaction/MathExpressionInteractionsView.kt index 26b606237c0..0807aa0a681 100644 --- a/app/src/main/java/org/oppia/android/app/customview/interaction/MathExpressionInteractionsView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/interaction/MathExpressionInteractionsView.kt @@ -34,23 +34,24 @@ class MathExpressionInteractionsView @JvmOverloads constructor( init { onFocusChangeListener = this + // Assume multi-line for the purpose of properly showing long hints. + setSingleLine(hint != null) hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { - hint = "" - typeface = Typeface.DEFAULT + hideHint() showSoftKeyboard(v, context) } else { - hint = hintText - if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + restoreHint() hideSoftKeyboard(v, context) } override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { clearFocus() + restoreHint() } return super.onKeyPreIme(keyCode, event) } @@ -73,4 +74,16 @@ class MathExpressionInteractionsView @JvmOverloads constructor( hint = placeholderText } } + + private fun hideHint() { + hint = "" + typeface = Typeface.DEFAULT + setSingleLine(true) + } + + private fun restoreHint() { + hint = hintText + if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + setSingleLine(false) + } } diff --git a/app/src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt b/app/src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt index 093795a1cb2..ef2cb2ea4cb 100644 --- a/app/src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt @@ -31,23 +31,25 @@ class NumericInputInteractionView @JvmOverloads constructor( init { onFocusChangeListener = this + // Assume multi-line for the purpose of properly showing long hints. + setSingleLine(hint != null) hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { - hint = "" - typeface = Typeface.DEFAULT + hideHint() showSoftKeyboard(v, context) } else { - hint = hintText - if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + restoreHint() hideSoftKeyboard(v, context) } override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { - if (event.keyCode == KEYCODE_BACK && event.action == ACTION_UP) - this.clearFocus() + if (event.keyCode == KEYCODE_BACK && event.action == ACTION_UP) { + clearFocus() + restoreHint() + } return super.onKeyPreIme(keyCode, event) } @@ -57,4 +59,16 @@ class NumericInputInteractionView @JvmOverloads constructor( } super.onEditorAction(actionCode) } + + private fun hideHint() { + hint = "" + typeface = Typeface.DEFAULT + setSingleLine(true) + } + + private fun restoreHint() { + hint = hintText + if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + setSingleLine(false) + } } diff --git a/app/src/main/java/org/oppia/android/app/customview/interaction/RatioInputInteractionView.kt b/app/src/main/java/org/oppia/android/app/customview/interaction/RatioInputInteractionView.kt index c3eb784514d..e0d1ccdaca1 100644 --- a/app/src/main/java/org/oppia/android/app/customview/interaction/RatioInputInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/interaction/RatioInputInteractionView.kt @@ -21,23 +21,25 @@ class RatioInputInteractionView @JvmOverloads constructor( init { onFocusChangeListener = this + // Assume multi-line for the purpose of properly showing long hints. + setSingleLine(hint != null) hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { - hint = "" - typeface = Typeface.DEFAULT + hideHint() KeyboardHelper.showSoftKeyboard(v, context) } else { - hint = hintText - if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + restoreHint() KeyboardHelper.hideSoftKeyboard(v, context) } override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { - if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) - this.clearFocus() + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + clearFocus() + restoreHint() + } return super.onKeyPreIme(keyCode, event) } @@ -47,4 +49,16 @@ class RatioInputInteractionView @JvmOverloads constructor( } super.onEditorAction(actionCode) } + + private fun hideHint() { + hint = "" + typeface = Typeface.DEFAULT + setSingleLine(true) + } + + private fun restoreHint() { + hint = hintText + if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + setSingleLine(false) + } } diff --git a/app/src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt b/app/src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt index d3400a10e51..3cf4f01f044 100644 --- a/app/src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt @@ -28,23 +28,25 @@ class TextInputInteractionView @JvmOverloads constructor( init { onFocusChangeListener = this + // Assume multi-line for the purpose of properly showing long hints. + setSingleLine(hint != null) hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { - hint = "" - typeface = Typeface.DEFAULT + hideHint() showSoftKeyboard(v, context) } else { - hint = hintText - if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + restoreHint() hideSoftKeyboard(v, context) } override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { - if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) - this.clearFocus() + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + clearFocus() + restoreHint() + } return super.onKeyPreIme(keyCode, event) } @@ -54,4 +56,16 @@ class TextInputInteractionView @JvmOverloads constructor( } super.onEditorAction(actionCode) } + + private fun hideHint() { + hint = "" + typeface = Typeface.DEFAULT + setSingleLine(true) + } + + private fun restoreHint() { + hint = hintText + if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) + setSingleLine(false) + } } diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt index 81228fc7243..8de03ce0ca3 100644 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt @@ -1,12 +1,15 @@ package org.oppia.android.app.player.audio +import androidx.databinding.ObservableBoolean import androidx.databinding.ObservableField import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.State import org.oppia.android.app.model.Voiceover import org.oppia.android.app.model.VoiceoverMapping +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel import org.oppia.android.domain.audio.AudioPlayerController import org.oppia.android.domain.audio.AudioPlayerController.PlayProgress @@ -14,6 +17,7 @@ import org.oppia.android.domain.audio.AudioPlayerController.PlayStatus import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.locale.OppiaLocale +import java.util.Locale import javax.inject.Inject /** [ObservableViewModel] for audio-player state. */ @@ -21,7 +25,8 @@ import javax.inject.Inject class AudioViewModel @Inject constructor( private val audioPlayerController: AudioPlayerController, @DefaultResourceBucketName private val gcsResource: String, - private val machineLocale: OppiaLocale.MachineLocale + private val machineLocale: OppiaLocale.MachineLocale, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private lateinit var state: State @@ -29,13 +34,15 @@ class AudioViewModel @Inject constructor( private var voiceoverMap = mapOf() private var currentContentId: String? = null private val defaultLanguage = "en" - private var languageSelectionShown = false private var autoPlay = false private var reloadingMainContent = false private var hasFeedback = false var selectedLanguageCode: String = "" + private var fallbackLanguageCode: String = defaultLanguage var languages = listOf() + var selectedLanguageUnavailable = ObservableBoolean() + var selectedLanguageName = ObservableField("") /** Mirrors PlayStatus in AudioPlayerController except adds LOADING state */ enum class UiAudioPlayStatus { @@ -90,22 +97,28 @@ class AudioViewModel @Inject constructor( voiceoverMap = voiceoverMapping.voiceoverMappingMap currentContentId = targetContentId languages = voiceoverMap.keys.toList().map { machineLocale.run { it.toMachineLowerCase() } } + selectedLanguageUnavailable.set(false) + + val localeLanguageCode = + if (selectedLanguageCode.isEmpty()) defaultLanguage else selectedLanguageCode + // TODO(#3791): Remove this dependency. + val locale = Locale(localeLanguageCode) + selectedLanguageName.set(locale.getDisplayLanguage(locale)) + when { selectedLanguageCode.isEmpty() && languages.any { it == defaultLanguage } -> setAudioLanguageCode(defaultLanguage) - languages.any { it == selectedLanguageCode } -> - setAudioLanguageCode(selectedLanguageCode) + languages.any { it == selectedLanguageCode } -> setAudioLanguageCode(selectedLanguageCode) languages.isNotEmpty() -> { autoPlay = false this.reloadingMainContent = false - languageSelectionShown = true - val languageCode = if (languages.contains("en")) { - "en" - } else { - languages.first() - } - setAudioLanguageCode(languageCode) + selectedLanguageUnavailable.set(true) + val ensuredLanguageCode = if (languages.contains("en")) "en" else languages.first() + fallbackLanguageCode = ensuredLanguageCode + audioPlayerController.changeDataSource( + voiceOverToUri(voiceoverMap[ensuredLanguageCode]), currentContentId + ) } } } @@ -132,6 +145,12 @@ class AudioViewModel @Inject constructor( fun handleSeekTo(position: Int) = audioPlayerController.seekTo(position) fun handleRelease() = audioPlayerController.releaseMediaPlayer() + fun computeAudioUnavailabilityString(languageName: String): String { + return resourceHandler.getStringInLocaleWithWrapping( + R.string.audio_unavailable_in_selected_language, languageName + ) + } + private val playProgressResultLiveData: LiveData> by lazy { audioPlayerController.initializeMediaPlayer() } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt index 4afebaccc72..9395c23cdb7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt @@ -4,6 +4,7 @@ import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField +import org.oppia.android.R import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer @@ -11,6 +12,7 @@ import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.domain.translation.TranslationController import javax.inject.Inject @@ -21,6 +23,7 @@ class TextInputViewModel private constructor( private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController ) : StateItemViewModel(ViewType.TEXT_INPUT_INTERACTION), InteractionAnswerHandler { var answerText: CharSequence = "" @@ -84,11 +87,16 @@ class TextInputViewModel private constructor( placeholderUnicodeOption2?.let { unicode -> translationController.extractString(unicode, writtenTranslationContext) } ?: "" // The default placeholder for text input is empty. - return if (placeholder1.isNotEmpty()) placeholder1 else placeholder2 + return when { + placeholder1.isNotEmpty() -> placeholder1 + placeholder2.isNotEmpty() -> placeholder2 + else -> resourceHandler.getStringInLocale(R.string.text_input_default_hint_text) + } } /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ class FactoryImpl @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController ) : InteractionItemFactory { override fun create( @@ -107,6 +115,7 @@ class TextInputViewModel private constructor( answerErrorReceiver, isSplitView, writtenTranslationContext, + resourceHandler, translationController ) } diff --git a/app/src/main/res/drawable/audio_language_availability_background.xml b/app/src/main/res/drawable/audio_language_availability_background.xml new file mode 100644 index 00000000000..5fd51aeab4c --- /dev/null +++ b/app/src/main/res/drawable/audio_language_availability_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_hint_bulb_white_48dp.xml b/app/src/main/res/drawable/ic_hint_bulb_white_48dp.xml index 6fc3005aee2..155f98bf313 100644 --- a/app/src/main/res/drawable/ic_hint_bulb_white_48dp.xml +++ b/app/src/main/res/drawable/ic_hint_bulb_white_48dp.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/layout/audio_fragment.xml b/app/src/main/res/layout/audio_fragment.xml index 63c5bbe3562..0859ca83748 100755 --- a/app/src/main/res/layout/audio_fragment.xml +++ b/app/src/main/res/layout/audio_fragment.xml @@ -21,57 +21,99 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" - android:background="@drawable/audio_background" - android:elevation="8dp" - android:gravity="center_vertical" - android:minHeight="48dp"> + android:paddingBottom="4dp" + android:clipToPadding="false" + android:gravity="center_vertical"> - + app:layout_constraintTop_toTopOf="parent"> + + + + - + - + + + + app:layout_constraintTop_toBottomOf="@+id/audio_bar_container"> + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b20559769e8..11aa6143dc9 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Navigation Menu Close Play audio Pause audio + %s audio is unavailable. OK Cancel Audio Language @@ -490,6 +491,7 @@ Enter a ratio in the form x:y. + Tap here to enter text. Smallest text size Largest text size Coming Soon diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt index 080310ccaf2..ecbe1f15c4d 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt @@ -1581,7 +1581,8 @@ class StateFragmentTest { // Verify that fraction input uses the standard text software keyboard. scenario.onActivity { activity -> val textView: TextView = activity.findViewById(R.id.fraction_input_interaction_view) - assertThat(textView.inputType).isEqualTo(InputType.TYPE_CLASS_TEXT) + assertThat(textView.inputType) + .isEqualTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE) } } } @@ -1602,7 +1603,8 @@ class StateFragmentTest { // Verify that ratio input uses the standard text software keyboard. scenario.onActivity { activity -> val textView: TextView = activity.findViewById(R.id.ratio_input_interaction_view) - assertThat(textView.inputType).isEqualTo(InputType.TYPE_CLASS_TEXT) + assertThat(textView.inputType) + .isEqualTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE) } } } diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt index d0fc42504eb..dd557d5d466 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt @@ -580,7 +580,9 @@ class ExplorationProgressController @Inject constructor( explorationProgress.stateDeck.submitAnswer( userAnswer, answerOutcome.feedback, answerOutcome.labelledAsCorrectAnswer ) - stateAnalyticsLogger?.logSubmitAnswer(answerOutcome.labelledAsCorrectAnswer) + stateAnalyticsLogger?.logSubmitAnswer( + topPendingState.interaction, userAnswer, answerOutcome.labelledAsCorrectAnswer + ) // Follow the answer's outcome to another part of the graph if it's different. val ephemeralState = computeBaseCurrentEphemeralState() diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt index 7616fe66fec..cf3990c544d 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLogger.kt @@ -10,10 +10,14 @@ import org.oppia.android.app.model.EventLog.LearnerDetailsContext import org.oppia.android.app.model.EventLog.PlayVoiceOverContext import org.oppia.android.app.model.EventLog.SubmitAnswerContext import org.oppia.android.app.model.Exploration +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.State +import org.oppia.android.app.model.UserAnswer import org.oppia.android.domain.oppialogger.LoggingIdentifierController import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.analytics.LearnerAnalyticsLogger.BaseLogger.Companion.maybeLogEvent +import org.oppia.android.util.math.toAnswerString import javax.inject.Inject import javax.inject.Singleton import org.oppia.android.app.model.EventLog.Context as EventContext @@ -312,10 +316,63 @@ class LearnerAnalyticsLogger @Inject constructor( /** * Logs that the learner submitted an answer, where [isCorrect] indicates whether the answer was - * labelled as correct. + * labelled as correct, and [userAnswer] was the actual structured answer submitted by the + * learner (in the provided [interaction]). */ - fun logSubmitAnswer(isCorrect: Boolean) { - logStateEvent(isCorrect, ::createSubmitAnswerContext, EventBuilder::setSubmitAnswerContext) + fun logSubmitAnswer(interaction: Interaction, userAnswer: UserAnswer, isCorrect: Boolean) { + val answer = userAnswer.answer + val stringifiedUserAnswer = when (answer.objectTypeCase) { + InteractionObject.ObjectTypeCase.NORMALIZED_STRING -> answer.normalizedString + InteractionObject.ObjectTypeCase.SIGNED_INT -> answer.signedInt.toString() + InteractionObject.ObjectTypeCase.REAL -> answer.real.toString() + InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT -> answer.nonNegativeInt.toString() + InteractionObject.ObjectTypeCase.FRACTION -> answer.fraction.toAnswerString() + InteractionObject.ObjectTypeCase.CLICK_ON_IMAGE -> + "(${answer.clickOnImage.clickPosition.x}, ${answer.clickOnImage.clickPosition.y})" + InteractionObject.ObjectTypeCase.RATIO_EXPRESSION -> answer.ratioExpression.toAnswerString() + InteractionObject.ObjectTypeCase.SET_OF_TRANSLATABLE_HTML_CONTENT_IDS -> { + val choices = interaction.customizationArgsMap["choices"] + ?.schemaObjectList + ?.schemaObjectList + ?.map { schemaObject -> schemaObject.customSchemaValue.subtitledHtml.contentId } + ?.toSet() + ?: setOf() + val contentIds = answer.setOfTranslatableHtmlContentIds.contentIdsList + contentIds.joinToString(prefix = "[", postfix = "]") { + choices.indexOf(it.contentId).toString() + } + } + InteractionObject.ObjectTypeCase.LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS -> { + val choices = interaction.customizationArgsMap["choices"] + ?.schemaObjectList + ?.schemaObjectList + ?.map { schemaObject -> schemaObject.customSchemaValue.subtitledHtml.contentId } + ?.toSet() + ?: setOf() + val contentIdLists = answer.listOfSetsOfTranslatableHtmlContentIds.contentIdListsList + contentIdLists.joinToString(prefix = "[", postfix = "]") { contentIdsList -> + contentIdsList.contentIdsList.joinToString(prefix = "[", postfix = "]") { + choices.indexOf(it.contentId).toString() + } + } + } + InteractionObject.ObjectTypeCase.MATH_EXPRESSION -> answer.mathExpression + InteractionObject.ObjectTypeCase.BOOL_VALUE, + InteractionObject.ObjectTypeCase.LIST_OF_SETS_OF_HTML_STRING, + InteractionObject.ObjectTypeCase.TRANSLATABLE_SET_OF_NORMALIZED_STRING, + InteractionObject.ObjectTypeCase.TRANSLATABLE_HTML_CONTENT_ID, + InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING, + InteractionObject.ObjectTypeCase.IMAGE_WITH_REGIONS, + InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS, + InteractionObject.ObjectTypeCase.OBJECTTYPE_NOT_SET, null -> null + } + + logStateEvent( + stringifiedUserAnswer, + isCorrect, + ::createSubmitAnswerContext, + EventBuilder::setSubmitAnswerContext + ) } /** @@ -345,6 +402,19 @@ class LearnerAnalyticsLogger @Inject constructor( ) } + private fun logStateEvent( + detail1: D1, + detail2: D2, + baseContextFactory: (D1, D2, ExplorationContext) -> T, + setter: EventBuilder.(T) -> EventBuilder + ) { + baseLogger.maybeLogEvent( + computeLogContext()?.let { + createAnalyticsEvent(baseContextFactory(detail1, detail2, it), setter) + }?.ensureNonEmpty() + ) + } + private fun computeLogContext(): ExplorationContext? { return baseExplorationLogContext?.toBuilder()?.apply { stateName = currentState.name @@ -448,9 +518,11 @@ class LearnerAnalyticsLogger @Inject constructor( }.build() private fun createSubmitAnswerContext( + stringifiedAnswer: String?, isAnswerCorrect: Boolean, explorationDetails: ExplorationContext ) = SubmitAnswerContext.newBuilder().apply { + stringifiedAnswer?.let { this.stringifiedAnswer = it } this.isAnswerCorrect = isAnswerCorrect this.explorationDetails = explorationDetails }.build() diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt index 091ef754ffc..40321352efb 100644 --- a/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt +++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/PlatformParameterAlphaKenyaModule.kt @@ -5,9 +5,11 @@ import dagger.Provides import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING import org.oppia.android.util.platformparameter.CACHE_LATEX_RENDERING_DEFAULT_VALUE import org.oppia.android.util.platformparameter.CacheLatexRendering +import org.oppia.android.util.platformparameter.ENABLE_EDIT_ACCOUNTS_OPTIONS_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_LANGUAGE_SELECTION_UI_DEFAULT_VALUE import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION import org.oppia.android.util.platformparameter.ENABLE_PERFORMANCE_METRICS_COLLECTION_DEFAULT_VALUE +import org.oppia.android.util.platformparameter.EnableEditAccountsOptionsUi import org.oppia.android.util.platformparameter.EnableLanguageSelectionUi import org.oppia.android.util.platformparameter.EnablePerformanceMetricsCollection import org.oppia.android.util.platformparameter.LEARNER_STUDY_ANALYTICS @@ -66,6 +68,14 @@ class PlatformParameterAlphaKenyaModule { ) } + @Provides + @EnableEditAccountsOptionsUi + fun provideEnableEditAccountsOptionsUi(): PlatformParameterValue { + return PlatformParameterValue.createDefaultParameter( + ENABLE_EDIT_ACCOUNTS_OPTIONS_UI_DEFAULT_VALUE + ) + } + @Provides @LearnerStudyAnalytics fun provideLearnerStudyAnalytics( diff --git a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt index ea47447f48c..29f8ce50ac2 100644 --- a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt +++ b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt @@ -205,7 +205,7 @@ class ProfileManagementController @Inject constructor( val deferred = profileDataStore.storeDataWithCustomChannelAsync( updateInMemoryCache = true ) { - if (!onlyLetters(name)) { + if (!learnerStudyAnalytics.value && !onlyLetters(name)) { return@storeDataWithCustomChannelAsync Pair(it, ProfileActionStatus.INVALID_PROFILE_NAME) } if (!isNameUnique(name, it)) { @@ -324,7 +324,7 @@ class ProfileManagementController @Inject constructor( val deferred = profileDataStore.storeDataWithCustomChannelAsync( updateInMemoryCache = true ) { - if (!onlyLetters(newName)) { + if (!learnerStudyAnalytics.value && !onlyLetters(newName)) { return@storeDataWithCustomChannelAsync Pair(it, ProfileActionStatus.INVALID_PROFILE_NAME) } if (!isNameUnique(newName, it)) { diff --git a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt index a1914c0eadc..1da3507e980 100644 --- a/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/LearnerAnalyticsLoggerTest.kt @@ -13,6 +13,8 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.oppia.android.app.model.Exploration +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.UserAnswer 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 @@ -824,7 +826,11 @@ class LearnerAnalyticsLoggerTest { val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) - stateLogger.logSubmitAnswer(isCorrect = false) + stateLogger.logSubmitAnswer( + interaction = Interaction.getDefaultInstance(), + userAnswer = UserAnswer.getDefaultInstance(), + isCorrect = false + ) val eventLog = fakeEventLogger.getMostRecentEvent() assertThat(eventLog).isEssentialPriority() @@ -851,7 +857,11 @@ class LearnerAnalyticsLoggerTest { val expLogger = learnerAnalyticsLogger.beginExploration(exploration5) val stateLogger = expLogger.startCard(exploration5.getStateByName(TEST_EXP_5_STATE_THREE_NAME)) - stateLogger.logSubmitAnswer(isCorrect = true) + stateLogger.logSubmitAnswer( + interaction = Interaction.getDefaultInstance(), + userAnswer = UserAnswer.getDefaultInstance(), + isCorrect = true + ) val eventLog = fakeEventLogger.getMostRecentEvent() assertThat(eventLog).isEssentialPriority() @@ -1306,7 +1316,11 @@ class LearnerAnalyticsLoggerTest { ) val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) - stateLogger.logSubmitAnswer(isCorrect = true) + stateLogger.logSubmitAnswer( + interaction = Interaction.getDefaultInstance(), + userAnswer = UserAnswer.getDefaultInstance(), + isCorrect = true + ) val eventLog = fakeEventLogger.getMostRecentEvent() assertThat(eventLog).hasSubmitAnswerContextThat { @@ -1326,7 +1340,11 @@ class LearnerAnalyticsLoggerTest { learnerAnalyticsLogger.beginExploration(exploration5, learnerId = null, installationId = null) val stateLogger = expLogger.startCard(exploration5.getStateByName(exploration5.initStateName)) - stateLogger.logSubmitAnswer(isCorrect = true) + stateLogger.logSubmitAnswer( + interaction = Interaction.getDefaultInstance(), + userAnswer = UserAnswer.getDefaultInstance(), + isCorrect = true + ) // See testExpLogger_logExitExploration_noInstallOrLearnerIds_logsEventAndConsoleErrors. val eventLog = fakeEventLogger.getMostRecentEvent() diff --git a/model/src/main/proto/oppia_logger.proto b/model/src/main/proto/oppia_logger.proto index 42bf2b21f4c..32c2290ffc2 100644 --- a/model/src/main/proto/oppia_logger.proto +++ b/model/src/main/proto/oppia_logger.proto @@ -191,6 +191,9 @@ message EventLog { // Defined attributes that are common among other exploration related event log contexts. ExplorationContext exploration_details = 1; + // A stringified representation of the answer submitted by the learner. + string stringified_answer = 2; + // Whether the answer that the learner submitted is the correct answer. bool is_answer_correct = 3; } diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 31cde32e18c..175be60d218 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -254,6 +254,7 @@ file_content_checks { file_path_regex: ".+?\\.kt" prohibited_content_regex: "java\\.util\\.Locale" failure_message: "Don't use Locale directly. Instead, use LocaleController, or OppiaLocale & its subclasses." + exempted_file_name: "app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt" exempted_file_name: "app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt" exempted_file_name: "app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt" 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 a35bb6caf4e..376f5fedb39 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 @@ -337,7 +337,12 @@ class EventBundleCreator @Inject constructor( value: SubmitAnswerEventContext ) : EventActivityContext(activityName, value) { override fun SubmitAnswerEventContext.storeValue(store: PropertyStore) { + // Note that values can't exceed 100 characters, so answers must be cut off. + val adjustedAnswer = if (stringifiedAnswer.length > 100) { + "${stringifiedAnswer.take(97)}..." + } else stringifiedAnswer store.putProperties("exploration_details", explorationDetails, ::ExplorationContext) + store.putNonSensitiveValue("submitted_answer", adjustedAnswer) store.putNonSensitiveValue("is_answer_correct", isAnswerCorrect.toString()) } } diff --git a/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt b/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt index 863a4d427f3..268b08e8a86 100644 --- a/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt +++ b/utility/src/test/java/org/oppia/android/util/logging/EventBundleCreatorTest.kt @@ -567,7 +567,7 @@ class EventBundleCreatorTest { val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) assertThat(typeName).isEqualTo("submit_answer_context") - assertThat(bundle).hasSize(9) + assertThat(bundle).hasSize(10) assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) assertThat(bundle).string("priority").isEqualTo("essential") assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) @@ -576,6 +576,7 @@ class EventBundleCreatorTest { assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("submitted_answer").isEmpty() assertThat(bundle).string("is_answer_correct").isEqualTo(TEST_IS_ANSWER_CORRECT_STR) } @@ -588,7 +589,7 @@ class EventBundleCreatorTest { val typeName = eventBundleCreator.fillEventBundle(eventLog, bundle) assertThat(typeName).isEqualTo("submit_answer_context") - assertThat(bundle).hasSize(11) + assertThat(bundle).hasSize(12) assertThat(bundle).longInt("timestamp").isEqualTo(TEST_TIMESTAMP_1) assertThat(bundle).string("priority").isEqualTo("essential") assertThat(bundle).string("ed_topic_id").isEqualTo(TEST_TOPIC_ID) @@ -597,6 +598,7 @@ class EventBundleCreatorTest { assertThat(bundle).string("ed_session_id").isEqualTo(TEST_LEARNER_SESSION_ID) assertThat(bundle).string("ed_exploration_version").isEqualTo(TEST_EXPLORATION_VERSION_STR) assertThat(bundle).string("ed_state_name").isEqualTo(TEST_STATE_NAME) + assertThat(bundle).string("submitted_answer").isEmpty() assertThat(bundle).string("is_answer_correct").isEqualTo(TEST_IS_ANSWER_CORRECT_STR) assertThat(bundle).string("ed_ld_learner_id").isEqualTo(TEST_LEARNER_ID) assertThat(bundle).string("ed_ld_install_id").isEqualTo(TEST_INSTALLATION_ID) diff --git a/version.bzl b/version.bzl index 73e2afe8558..51dba48bd38 100644 --- a/version.bzl +++ b/version.bzl @@ -7,12 +7,12 @@ their device qualifies for more than one choice. """ MAJOR_VERSION = 0 -MINOR_VERSION = 7 +MINOR_VERSION = 8 # TODO(#4419): Remove the Kenya-specific alpha version code. # TODO(#4348): Offset these version codes by '+1' for the next release. -OPPIA_DEV_KITKAT_VERSION_CODE = 22 -OPPIA_DEV_VERSION_CODE = 23 -OPPIA_ALPHA_KITKAT_VERSION_CODE = 24 -OPPIA_ALPHA_VERSION_CODE = 25 -OPPIA_ALPHA_KENYA_VERSION_CODE = 26 +OPPIA_DEV_KITKAT_VERSION_CODE = 27 +OPPIA_DEV_VERSION_CODE = 28 +OPPIA_ALPHA_KITKAT_VERSION_CODE = 29 +OPPIA_ALPHA_VERSION_CODE = 30 +OPPIA_ALPHA_KENYA_VERSION_CODE = 31