Skip to content

Commit

Permalink
Fix #4606: Language selector feature implementation (#4762)
Browse files Browse the repository at this point in the history
## Explanation

Fixes part of #52
Fixes #4606

 Feature implementation for #4606
- This PR introduces the ability to change App level language through
the Options setting. It has involved below changes.
1. Refactored the codebase to accomodate feature changes by changing
from use of string variable to OppiaLanguage
            proto enum object.
2. Add Changes to allow display of languages loaded from proto in a
human readable form similar to AudioLanguage
           representation. 
- When fully functional this PR should allow users to modify app
language in App settings regardless of the language setting of their
device. PersistentCache is used to locally persist language which has
been selected by a user.

See also KevinGitonga#5 for an
additional explanation of more specific changes contained within this
PR.

## Essential Checklist
<!-- Please tick the relevant boxes by putting an "x" in them. -->
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

## For UI-specific PRs only
<!-- Delete these section if this PR does not include UI-related
changes. -->
If your PR includes UI-related changes, then:
- Add screenshots for portrait/landscape for both a tablet & phone of
the before & after UI changes
- For the screenshots above, include both English and pseudo-localized
(RTL) screenshots (see [RTL
guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines))
- Add a video showing the full UX flow with a screen reader enabled (see
[accessibility
guide](https://github.com/oppia/oppia-android/wiki/Accessibility-(A11y)-Guide))
- Add a screenshot demonstrating that you ran affected Espresso tests
locally & that they're passing

## Video demo of changes

https://user-images.githubusercontent.com/20886444/222683790-7b2d4fa8-7068-4774-8d1a-0494d4f9adf0.mp4

---------

Co-authored-by: Ben Henning <[email protected]>
  • Loading branch information
KevinGitonga and BenHenning authored Jun 1, 2023
1 parent 6de40f0 commit 81a95b7
Show file tree
Hide file tree
Showing 213 changed files with 2,210 additions and 1,009 deletions.
1 change: 1 addition & 0 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ package_group(
multidex = apk_flavor_metadata["multidex"],
deps = [
"//app/src/main/java/org/oppia/android/app/application/dev:developer_application",
"//config/src/java/org/oppia/android/config:all_languages_config",
],
)
for apk_flavor_metadata in [
Expand Down
3 changes: 2 additions & 1 deletion app/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,8 @@ kt_android_library(
":view_models",
":views",
"//app/src/main/java/org/oppia/android/app/activity:activity_intent_factories_shim",
"//app/src/main/java/org/oppia/android/app/activity:injectable_app_compat_activity",
"//app/src/main/java/org/oppia/android/app/activity:injectable_auto_localized_app_compat_activity",
"//app/src/main/java/org/oppia/android/app/activity:injectable_system_localized_app_compat_activity",
"//app/src/main/java/org/oppia/android/app/activity/route:activity_router",
"//app/src/main/java/org/oppia/android/app/fragment:injectable_bottom_sheet_dialog_fragment",
"//app/src/main/java/org/oppia/android/app/fragment:injectable_dialog_fragment",
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ def filesToExclude = [
'**/*AppLanguageLocaleHandlerTest*.kt',
'**/*AppLanguageResourceHandlerTest*.kt',
'**/*AppLanguageWatcherMixinTest*.kt',
'**/*ActivityLanguageLocaleHandlerTest*.kt'
]
_excludeSourceFiles(filesToExclude)

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/org/oppia/android/app/activity/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ kt_android_library(
],
)

kt_android_library(
name = "injectable_auto_localized_app_compat_activity",
srcs = ["InjectableAutoLocalizedAppCompatActivity.kt"],
visibility = ["//app:app_visibility"],
deps = [":injectable_app_compat_activity"],
)

kt_android_library(
name = "injectable_system_localized_app_compat_activity",
srcs = ["InjectableSystemLocalizedAppCompatActivity.kt"],
visibility = ["//app:app_visibility"],
deps = [":injectable_app_compat_activity"],
)

kt_android_library(
name = "activity_component",
srcs = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import org.oppia.android.app.fragment.FragmentComponentBuilderInjector
import org.oppia.android.app.fragment.FragmentComponentFactory
import org.oppia.android.app.translation.AppLanguageActivityInjector
import org.oppia.android.app.translation.AppLanguageActivityInjectorProvider
import org.oppia.android.app.translation.AppLanguageApplicationInjectorProvider

import org.oppia.android.app.translation.AppLanguageWatcherMixin
/**
* An [AppCompatActivity] that facilitates field injection to child activities and constituent
* fragments that extend [org.oppia.android.app.fragment.InjectableFragment].
Expand All @@ -31,7 +30,7 @@ abstract class InjectableAppCompatActivity :
"Expected attached Context to have an application context defined."
}
onInitializeActivityComponent(applicationContext)
val newConfiguration = onInitializeLocalization(applicationContext, newBase)
val newConfiguration = onInitializeLocalization(newBase)
super.attachBaseContext(newBase?.createConfigurationContext(newConfiguration))
}

Expand All @@ -58,26 +57,33 @@ abstract class InjectableAppCompatActivity :
}

private fun onInitializeLocalization(
applicationContext: Context,
newBase: Context?
): Configuration {
// Given how DataProviders work (i.e. by resolving data races using eventual consistency), it's
// possible to miss some updates in really unlikely situations. No additional work will be done
// to prevent these data races unless they're actually hit by users. It shouldn't, in practice,
// be possible since it requires changing the system language between activity transitions, and
// in most cases that should result in an activity recreation by the mixin, anyway.
val appLanguageAppInjectorProvider =
applicationContext as AppLanguageApplicationInjectorProvider
val appLanguageAppInjector = appLanguageAppInjectorProvider.getAppLanguageApplicationInjector()
val appLanguageActivityInjector = activityComponent as AppLanguageActivityInjector
val appLanguageLocaleHandler = appLanguageAppInjector.getAppLanguageHandler()
val appLanguageWatcherMixin = appLanguageActivityInjector.getAppLanguageWatcherMixin()
appLanguageWatcherMixin.initialize()
val activityLanguageInjectorProvider = this as AppLanguageActivityInjectorProvider
val activityLanguageActivityInjector =
activityLanguageInjectorProvider.getAppLanguageActivityInjector()
val activityLanguageLocaleHandler =
activityLanguageActivityInjector.getActivityLanguageHandler()

val appLanguageWatcherMixin = activityLanguageActivityInjector.getAppLanguageWatcherMixin()
initializeMixin(appLanguageWatcherMixin)

return Configuration(newBase?.resources?.configuration).also { newConfiguration ->
appLanguageLocaleHandler.initializeLocaleForActivity(newConfiguration)
activityLanguageLocaleHandler.initializeLocaleForActivity(newConfiguration)
}
}

/**
* Initializes [appLanguageWatcherMixin] based on the localization requirements of the implementation.
* This should never be directly implemented by non-abstract activity classes.
*/
abstract fun initializeMixin(appLanguageWatcherMixin: AppLanguageWatcherMixin)

private fun ensureLayoutDirection() {
// Ensure the root decor view has the correct layout direct setup per the base context. In some
// cases, Android will let the app recreate the activity & properly update the layout direction
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.oppia.android.app.activity

import androidx.appcompat.app.AppCompatActivity
import org.oppia.android.app.translation.AppLanguageWatcherMixin

/**
* An [AppCompatActivity] that facilitates field injection to child activities and constituent
* fragments that extend [org.oppia.android.app.fragment.InjectableFragment].
*
* This should be extended by all activities which should be automatically localized based on the
* user's selected app language.
*/
abstract class InjectableAutoLocalizedAppCompatActivity : InjectableAppCompatActivity() {

override fun initializeMixin(appLanguageWatcherMixin: AppLanguageWatcherMixin) {
appLanguageWatcherMixin.initialize(shouldOnlyUseSystemLanguage = false)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.oppia.android.app.activity

import androidx.appcompat.app.AppCompatActivity
import org.oppia.android.app.translation.AppLanguageWatcherMixin

/**
* An [AppCompatActivity] that facilitates field injection to child activities and constituent
* fragments that extend [org.oppia.android.app.fragment.InjectableFragment].
*
* This should be extended by all activities which should be system localized or use the
* device default language.
*/
abstract class InjectableSystemLocalizedAppCompatActivity : InjectableAppCompatActivity() {

override fun initializeMixin(appLanguageWatcherMixin: AppLanguageWatcherMixin) {
appLanguageWatcherMixin.initialize(shouldOnlyUseSystemLanguage = true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import android.content.Intent
import android.os.Bundle
import org.oppia.android.R
import org.oppia.android.app.activity.ActivityComponentImpl
import org.oppia.android.app.activity.InjectableAppCompatActivity
import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
import org.oppia.android.app.administratorcontrols.appversion.AppVersionActivity
import org.oppia.android.app.administratorcontrols.learneranalytics.ProfileAndDeviceIdActivity
import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY
Expand Down Expand Up @@ -47,7 +47,7 @@ const val PROFILE_AND_DEVICE_ID_FRAGMENT = "PROFILE_AND_DEVICE_ID_FRAGMENT"

/** Activity [AdministratorControlsActivity] that allows user to change admin controls. */
class AdministratorControlsActivity :
InjectableAppCompatActivity(),
InjectableAutoLocalizedAppCompatActivity(),
RouteToProfileListListener,
RouteToAppVersionListener,
RouteToLearnerAnalyticsListener,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,17 @@ class AdministratorControlsFragmentPresenter @Inject constructor(
}

private enum class ViewType {
/** Represents [View] for the general section. */
VIEW_TYPE_GENERAL,
/** Represents [View] for the profile section. */
VIEW_TYPE_PROFILE,
/** Represents [View] for the download permissions section. */
VIEW_TYPE_DOWNLOAD_PERMISSIONS,
/** Represents [View] for the app information section. */
VIEW_TYPE_APP_INFORMATION,
/** Represents [View] for the account actions section. */
VIEW_TYPE_ACCOUNT_ACTIONS,
/** Represents [View] for the learner analytics section. */
VIEW_TYPE_LEARNER_ANALYTICS
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import org.oppia.android.app.profile.ProfileChooserActivity
class LogoutDialogFragment : InjectableDialogFragment() {

companion object {
/** [String] key to access [LogoutDialogFragment]. */
const val TAG_LOGOUT_DIALOG_FRAGMENT = "TAG_LOGOUT_DIALOG_FRAGMENT"

/** Returns a new [LogoutDialogFragment] instance. */
fun newInstance(): LogoutDialogFragment {
return LogoutDialogFragment()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ package org.oppia.android.app.administratorcontrols

/** Listener for when an activity should route to [AppVersionActivity]. */
interface RouteToAppVersionListener {
/** Called when [AppVersionActivity] should be loaded. */
fun routeToAppVersion()
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.oppia.android.app.administratorcontrols.ShowLogoutDialogListener
class AdministratorControlsAccountActionsViewModel(
private val showLogoutDialogListener: ShowLogoutDialogListener
) : AdministratorControlsItemViewModel() {

/** Called when user clicks logout on [AdministratorControlsActivity]. */
fun onLogOutClicked() {
showLogoutDialogListener.showLogoutDialog()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class AdministratorControlsAppInformationViewModel(
private val routeToAppVersionListener = activity as RouteToAppVersionListener
private val loadAppVersionListener = activity as LoadAppVersionListener

/** Called when a user clicks on AppVersion in [AdministratorControlsActivity]. */
fun onAppVersionClicked() {
if (isMultipane.get()!!) {
loadAppVersionListener.loadAppVersion()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ class AdministratorControlsDownloadPermissionsViewModel(
private val userProfileId: ProfileId,
deviceSettings: DeviceSettings
) : AdministratorControlsItemViewModel() {
/**
* [Boolean] observable value showing if topic downloads and updates should happen only on Wifi.
*/
val isTopicWifiUpdatePermission =
ObservableField<Boolean>(deviceSettings.allowDownloadAndUpdateOnlyOnWifi)
/** [Boolean] observable value showing if topic updates should happen automatically. */
val isTopicAutoUpdatePermission =
ObservableField<Boolean>(deviceSettings.automaticallyUpdateTopics)

/** Called when topic wifi update permission changes. */
fun onTopicWifiUpdatePermissionChanged() {
profileManagementController.updateWifiPermissionDeviceSettings(
userProfileId,
Expand All @@ -42,6 +47,7 @@ class AdministratorControlsDownloadPermissionsViewModel(
)
}

/** Called when topic auto update permission changes. */
fun onTopicAutoUpdatePermissionChanged() {
profileManagementController.updateTopicAutomaticallyPermissionDeviceSettings(
userProfileId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ package org.oppia.android.app.administratorcontrols.administratorcontrolsitemvie
import androidx.databinding.ObservableField
import org.oppia.android.app.viewmodel.ObservableViewModel

/** Super-class for generalising different views for the recyclerView in [AdministratorControlsFragment] */
/**
* Super-class for generalising different views for the recyclerView in
* [AdministratorControlsFragment].
*/
abstract class AdministratorControlsItemViewModel : ObservableViewModel() {
/** [Boolean] observable value showing if [View] is multipane. */
val isMultipane = ObservableField<Boolean>(false)
/** [Int] representing the index of items bound in [AdministratorControlsActivity]. */
val itemIndex = ObservableField<Int>()
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class AdministratorControlsProfileViewModel(
private val loadProfileListListener: LoadProfileListListener
) : AdministratorControlsItemViewModel() {

/** Called when a user clicks on EditProfiles in [AdministratorControlsActivity]. */
fun onEditProfilesClicked() {
if (isMultipane.get()!!) {
loadProfileListListener.loadProfileList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import org.oppia.android.app.activity.ActivityComponentImpl
import org.oppia.android.app.activity.InjectableAppCompatActivity
import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
import org.oppia.android.app.model.ScreenName.APP_VERSION_ACTIVITY
import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
import javax.inject.Inject

/** Activity for App Version. */
class AppVersionActivity : InjectableAppCompatActivity() {
class AppVersionActivity : InjectableAutoLocalizedAppCompatActivity() {
@Inject
lateinit var appVersionActivityPresenter: AppVersionActivityPresenter

Expand All @@ -29,6 +29,7 @@ class AppVersionActivity : InjectableAppCompatActivity() {
}

companion object {
/** Returns an [Intent] to start this activity. */
fun createAppVersionActivityIntent(context: Context): Intent {
return Intent(context, AppVersionActivity::class.java).apply {
decorateWithScreenName(APP_VERSION_ACTIVITY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import javax.inject.Inject
/** The presenter for [AppVersionActivity]. */
@ActivityScope
class AppVersionActivityPresenter @Inject constructor(private val activity: AppCompatActivity) {

/** Initializes the [AppVersionActivity] views and binds [AppVersionFragment]. */
fun handleOnCreate() {
activity.setContentView(R.layout.app_version_activity)
setToolbar()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class AppVersionFragmentPresenter @Inject constructor(
) {
private lateinit var binding: AppVersionFragmentBinding

/** Initializes and creates the views for the [AppVersionFragment]. */
fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? {
binding = AppVersionFragmentBinding.inflate(
inflater,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import org.oppia.android.app.utility.getVersionName
import org.oppia.android.app.viewmodel.ObservableViewModel
import javax.inject.Inject

/** [ViewModel] for [AppVersionFragment]*/
/** [ViewModel] for [AppVersionFragment]. */
@FragmentScope
class AppVersionViewModel @Inject constructor(
private val resourceHandler: AppLanguageResourceHandler,
Expand All @@ -20,9 +20,11 @@ class AppVersionViewModel @Inject constructor(
private val versionName: String = context.getVersionName()
private val lastUpdateDateTime = context.getLastUpdateTime()

/** Returns a localized, human-readable app version name. */
fun computeVersionNameText(): String =
resourceHandler.getStringInLocaleWithWrapping(R.string.app_version_name, versionName)

/** Returns a localized, human-readable lastUpdateDateTime. */
fun computeLastUpdatedDateText(): String =
resourceHandler.getStringInLocaleWithWrapping(
R.string.app_last_update_date, getDateTime(lastUpdateDateTime)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import org.oppia.android.app.activity.ActivityComponentImpl
import org.oppia.android.app.activity.InjectableAppCompatActivity
import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
import org.oppia.android.app.model.ScreenName.PROFILE_AND_DEVICE_ID_ACTIVITY
import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
import javax.inject.Inject
Expand All @@ -17,7 +17,7 @@ import javax.inject.Inject
* These IDs are meant to help facilitators of app user studies correspond specific logged events to
* a particular user or group.
*/
class ProfileAndDeviceIdActivity : InjectableAppCompatActivity() {
class ProfileAndDeviceIdActivity : InjectableAutoLocalizedAppCompatActivity() {
@Inject lateinit var profileAndDeviceIdActivityPresenter: ProfileAndDeviceIdActivityPresenter

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ class CompletedStoryItemViewModel(
private val intentFactoryShim: IntentFactoryShim,
translationController: TranslationController
) : ObservableViewModel(), RouteToTopicPlayStoryListener {
/** Holds lazily loaded completedStoryName [String] value. */
val completedStoryName by lazy {
translationController.extractString(
completedStory.storyTitle, completedStory.storyWrittenTranslationContext
)
}
/** Holds lazily loaded topicName [String] value. */
val topicName by lazy {
translationController.extractString(
completedStory.topicTitle, completedStory.topicWrittenTranslationContext
)
}

/** Called when user clicks on CompletedStoryItem. */
fun onCompletedStoryItemClicked() {
routeToTopicPlayStory(internalProfileId, completedStory.topicId, completedStory.storyId)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ 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.InjectableAppCompatActivity
import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
import org.oppia.android.app.model.ScreenName.COMPLETED_STORY_LIST_ACTIVITY
import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
import javax.inject.Inject

/** Activity for completed stories. */
class CompletedStoryListActivity : InjectableAppCompatActivity() {
class CompletedStoryListActivity : InjectableAutoLocalizedAppCompatActivity() {
@Inject
lateinit var completedStoryListActivityPresenter: CompletedStoryListActivityPresenter

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import javax.inject.Inject
class CompletedStoryListActivityPresenter @Inject constructor(
private val activity: AppCompatActivity
) {

/** Initializes views for [CompletedStoryListActivity] and binds [CompletedStoryListFragment]. */
fun handleOnCreate(internalProfileId: Int) {
activity.setContentView(R.layout.completed_story_list_activity)
if (getCompletedStoryListFragment() == null) {
Expand Down
Loading

0 comments on commit 81a95b7

Please sign in to comment.