Skip to content

Commit

Permalink
Merge branch 'localization-part6-introduce-content-and-answer-transla…
Browse files Browse the repository at this point in the history
…tions-support' into localization-part7-consolidate-audio-written-language-and-add-gate
  • Loading branch information
BenHenning committed Sep 15, 2021
2 parents 2ef0ea7 + fafe5c3 commit 68f6c1d
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 134 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package org.oppia.android.domain.locale

import android.os.Build
import java.util.Locale
import javax.inject.Inject
import org.oppia.android.app.model.LanguageSupportDefinition
import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId
import org.oppia.android.app.model.OppiaLocaleContext
import org.oppia.android.app.model.RegionSupportDefinition
import org.oppia.android.util.locale.OppiaLocale

class AndroidLocaleFactory @Inject constructor(
private val machineLocale: OppiaLocale.MachineLocale
) {
fun createAndroidLocale(localeContext: OppiaLocaleContext): Locale {
val languageId = localeContext.getLanguageId()
val fallbackLanguageId = localeContext.getFallbackLanguageId()

// Locale is always computed based on the Android resource app string identifier if that's
// defined. If it isn't, the routine falls back to app language & region country codes (which
// also provides interoperability with system-derived contexts). Note that if either identifier
// is missing for the primary language, the fallback is used instead (if available), except that
// IETF BCP 47 tags from the primary language are used before Android resource codes from the
// fallback. Thus, the order of this list is important. Finally, a basic check is done here to
// make sure this version of Android can actually render the target language.
val potentialProfiles =
computePotentialLanguageProfiles(localeContext, languageId) +
computePotentialFallbackLanguageProfiles(localeContext, fallbackLanguageId)

// Either find the first supported profile or force the locale to use the exact definition
// values, depending on whether to fail over to a forced locale.
val firstSupportedProfile = potentialProfiles.findFirstSupported()
val selectedProfile = firstSupportedProfile
?: languageId.computeForcedProfile(localeContext.regionDefinition)
return Locale(selectedProfile.languageCode, selectedProfile.regionCode)
}

private fun computePotentialLanguageProfiles(
localeContext: OppiaLocaleContext,
languageId: LanguageId
): List<AndroidLocaleProfile?> =
computeLanguageProfiles(localeContext, localeContext.languageDefinition, languageId)

private fun computePotentialFallbackLanguageProfiles(
localeContext: OppiaLocaleContext,
fallbackLanguageId: LanguageId
): List<AndroidLocaleProfile?> {
return computeLanguageProfiles(
localeContext, localeContext.fallbackLanguageDefinition, fallbackLanguageId
)
}

private fun computeLanguageProfiles(
localeContext: OppiaLocaleContext,
definition: LanguageSupportDefinition,
languageId: LanguageId
): List<AndroidLocaleProfile?> {
return if (definition.minAndroidSdkVersion <= Build.VERSION.SDK_INT) {
listOf(
languageId.computeLocaleProfileFromAndroidId(),
languageId.computeLocaleProfileFromIetfDefinitions(localeContext.regionDefinition),
languageId.computeLocaleProfileFromMacaronicLanguage()
)
} else listOf()
}

private fun LanguageId.computeLocaleProfileFromAndroidId(): AndroidLocaleProfile? {
return if (hasAndroidResourcesLanguageId()) {
androidResourcesLanguageId.run { maybeConstructProfile(languageCode, regionCode) }
} else null
}

private fun LanguageId.computeLocaleProfileFromIetfDefinitions(
regionDefinition: RegionSupportDefinition
): AndroidLocaleProfile? {
if (!hasIetfBcp47Id()) return null
if (!regionDefinition.hasRegionId()) return null
return maybeConstructProfile(
ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag
)
}

private fun LanguageId.computeLocaleProfileFromMacaronicLanguage(): AndroidLocaleProfile? {
if (!hasMacaronicId()) return null
val (languageCode, regionCode) = macaronicId.combinedLanguageCode.divide("-") ?: return null
return maybeConstructProfile(languageCode, regionCode)
}

/**
* Returns an [AndroidLocaleProfile] for this [LanguageId] and the specified
* [RegionSupportDefinition] based on the language's & region's IETF BCP 47 codes regardless of
* whether they're defined (i.e. it's fine to default to empty string here since that will
* leverage Android's own root locale behavior).
*/
private fun LanguageId.computeForcedProfile(
regionDefinition: RegionSupportDefinition
): AndroidLocaleProfile {
return AndroidLocaleProfile(
ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag
)
}

private fun maybeConstructProfile(
languageCode: String, regionCode: String
): AndroidLocaleProfile? {
return if (languageCode.isNotEmpty() && regionCode.isNotEmpty()) {
AndroidLocaleProfile(languageCode, regionCode)
} else null
}

private fun List<AndroidLocaleProfile?>.findFirstSupported(): AndroidLocaleProfile? = find {
it?.let { profileToMatch ->
availableLocaleProfiles.any { availableProfile ->
availableProfile.matches(machineLocale, profileToMatch)
}
} ?: false // Ignore null profiles.
}

private companion object {
private val availableLocaleProfiles by lazy {
Locale.getAvailableLocales().map(AndroidLocaleProfile::createFrom)
}

private fun String.divide(delimiter: String): Pair<String, String>? {
val results = split(delimiter)
return if (results.size == 2) {
results[0] to results[1]
} else null
}
}
}
24 changes: 23 additions & 1 deletion domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,34 @@ kt_android_library(
)

kt_android_library(
name = "display_locale_impl",
name = "android_locale_factory",
srcs = [
"AndroidLocaleFactory.kt",
],
deps = [
":android_locale_profile",
":dagger",
],
)

kt_android_library(
name = "android_locale_profile",
srcs = [
"AndroidLocaleProfile.kt",
],
deps = [
"//utility/src/main/java/org/oppia/android/util/locale:oppia_locale",
],
)

kt_android_library(
name = "display_locale_impl",
srcs = [
"DisplayLocaleImpl.kt",
],
deps = [
":android_locale_factory",
"//third_party:androidx_core_core",
"//utility/src/main/java/org/oppia/android/util/data:data_providers",
"//utility/src/main/java/org/oppia/android/util/locale:oppia_locale",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.os.Build
import android.text.BidiFormatter
import androidx.annotation.ArrayRes
import androidx.annotation.StringRes
import androidx.core.text.TextUtilsCompat
import java.text.DateFormat
import java.util.Date
import java.util.Locale
Expand All @@ -19,10 +20,11 @@ import org.oppia.android.util.locale.OppiaLocale
// TODO(#3766): Restrict to be 'internal'.
class DisplayLocaleImpl(
localeContext: OppiaLocaleContext,
private val machineLocale: MachineLocale
private val machineLocale: MachineLocale,
private val androidLocaleFactory: AndroidLocaleFactory
): OppiaLocale.DisplayLocale(localeContext) {
// TODO(#3766): Restrict to be 'internal'.
val formattingLocale: Locale by lazy { computeLocale() }
val formattingLocale: Locale by lazy { androidLocaleFactory.createAndroidLocale(localeContext) }
private val dateFormat by lazy {
DateFormat.getDateInstance(DATE_FORMAT_LENGTH, formattingLocale)
}
Expand All @@ -48,6 +50,10 @@ class DisplayLocaleImpl(
override fun computeDateTimeString(timestampMillis: Long): String =
dateTimeFormat.format(Date(timestampMillis))

override fun getLayoutDirection(): Int {
return TextUtilsCompat.getLayoutDirectionFromLocale(formattingLocale)
}

override fun String.formatInLocale(vararg args: Any?): String =
format(formattingLocale, *args.map { arg ->
if (arg is CharSequence) bidiFormatter.unicodeWrap(arg) else arg
Expand Down Expand Up @@ -83,110 +89,8 @@ class DisplayLocaleImpl(

override fun hashCode(): Int = Objects.hash(localeContext, machineLocale)

private fun computeLocale(): Locale {
// Locale is always computed based on the Android resource app string identifier if that's
// defined. If it isn't, the routine falls back to app language & region country codes (which
// also provides interoperability with system-derived contexts). Note that if either identifier
// is missing for the primary language, the fallback is used instead (if available), except that
// IETF BCP 47 tags from the primary language are used before Android resource codes from the
// fallback. Thus, the order of this list is important. Finally, a basic check is done here to
// make sure this version of Android can actually render the target language.
val potentialProfiles =
computePotentialLanguageProfiles() + computePotentialFallbackLanguageProfiles()

// Either find the first supported profile or force the locale to use the exact definition
// values.
val selectedProfile =
potentialProfiles.findFirstSupported()
?: getLanguageId().computeForcedProfile(localeContext.regionDefinition)

return Locale(selectedProfile.languageCode, selectedProfile.regionCode)
}

private fun computePotentialLanguageProfiles(): List<AndroidLocaleProfile?> =
computeLanguageProfiles(localeContext.languageDefinition, getLanguageId())

private fun computePotentialFallbackLanguageProfiles(): List<AndroidLocaleProfile?> =
computeLanguageProfiles(localeContext.fallbackLanguageDefinition, getFallbackLanguageId())

private fun computeLanguageProfiles(
definition: LanguageSupportDefinition,
languageId: LanguageId
): List<AndroidLocaleProfile?> {
return if (definition.minAndroidSdkVersion <= Build.VERSION.SDK_INT) {
listOf(
languageId.computeLocaleProfileFromAndroidId(),
languageId.computeLocaleProfileFromIetfDefinitions(localeContext.regionDefinition),
languageId.computeLocaleProfileFromMacaronicLanguage()
)
} else listOf()
}

private fun LanguageId.computeLocaleProfileFromAndroidId(): AndroidLocaleProfile? {
return if (hasAndroidResourcesLanguageId()) {
androidResourcesLanguageId.run { maybeConstructProfile(languageCode, regionCode) }
} else null
}

private fun LanguageId.computeLocaleProfileFromIetfDefinitions(
regionDefinition: RegionSupportDefinition
): AndroidLocaleProfile? {
if (!hasIetfBcp47Id()) return null
if (!regionDefinition.hasRegionId()) return null
return maybeConstructProfile(
ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag
)
}

private fun LanguageId.computeLocaleProfileFromMacaronicLanguage(): AndroidLocaleProfile? {
if (!hasMacaronicId()) return null
val (languageCode, regionCode) = macaronicId.combinedLanguageCode.divide("-") ?: return null
return maybeConstructProfile(languageCode, regionCode)
}

/**
* Returns an [AndroidLocaleProfile] for this [LanguageId] and the specified
* [RegionSupportDefinition] based on the language's & region's IETF BCP 47 codes regardless of
* whether they're defined (i.e. it's fine to default to empty string here since that will
* leverage Android's own root locale behavior).
*/
private fun LanguageId.computeForcedProfile(
regionDefinition: RegionSupportDefinition
): AndroidLocaleProfile {
return AndroidLocaleProfile(
ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag
)
}

private fun maybeConstructProfile(
languageCode: String, regionCode: String
): AndroidLocaleProfile? {
return if (languageCode.isNotEmpty() && regionCode.isNotEmpty()) {
AndroidLocaleProfile(languageCode, regionCode)
} else null
}

private fun List<AndroidLocaleProfile?>.findFirstSupported(): AndroidLocaleProfile? = find {
it?.let { profileToMatch ->
availableLocaleProfiles.any { availableProfile ->
availableProfile.matches(machineLocale, profileToMatch)
}
} ?: false // Ignore null profiles.
}

private companion object {
private const val DATE_FORMAT_LENGTH = DateFormat.LONG
private const val TIME_FORMAT_LENGTH = DateFormat.SHORT

private val availableLocaleProfiles by lazy {
Locale.getAvailableLocales().map(AndroidLocaleProfile::createFrom)
}
}
}

private fun String.divide(delimiter: String): Pair<String, String>? {
val results = split(delimiter)
return if (results.size == 2) {
results[0] to results[1]
} else null
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,34 @@ package org.oppia.android.domain.locale

import android.content.Context
import android.content.res.Configuration
import org.oppia.android.app.model.LanguageSupportDefinition
import org.oppia.android.app.model.OppiaLanguage
import org.oppia.android.app.model.OppiaLocaleContext
import org.oppia.android.app.model.OppiaRegion
import org.oppia.android.app.model.RegionSupportDefinition
import org.oppia.android.app.model.SupportedLanguages
import org.oppia.android.app.model.SupportedRegions
import org.oppia.android.util.data.DataProvider
import java.util.Locale
import org.oppia.android.domain.oppialogger.OppiaLogger
import java.util.concurrent.locks.ReentrantLock
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.withLock
import org.oppia.android.app.model.LanguageSupportDefinition
import org.oppia.android.app.model.OppiaLanguage
import org.oppia.android.app.model.OppiaLocaleContext
import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode
import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.APP_STRINGS
import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS
import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS
import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.UNRECOGNIZED
import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.USAGE_MODE_UNSPECIFIED
import org.oppia.android.util.locale.OppiaLocale.ContentLocale
import org.oppia.android.util.locale.OppiaLocale.DisplayLocale
import org.oppia.android.util.locale.OppiaLocale.MachineLocale
import org.oppia.android.app.model.OppiaRegion
import org.oppia.android.app.model.RegionSupportDefinition
import org.oppia.android.app.model.SupportedLanguages
import org.oppia.android.app.model.SupportedRegions
import org.oppia.android.domain.oppialogger.OppiaLogger
import org.oppia.android.util.data.AsyncDataSubscriptionManager
import org.oppia.android.util.data.AsyncResult
import org.oppia.android.util.data.DataProvider
import org.oppia.android.util.data.DataProviders
import org.oppia.android.util.data.DataProviders.Companion.transformAsync
import org.oppia.android.util.locale.OppiaLocale
import org.oppia.android.util.locale.OppiaLocale.ContentLocale
import org.oppia.android.util.locale.OppiaLocale.DisplayLocale
import org.oppia.android.util.locale.OppiaLocale.MachineLocale

// TODO: document how notifications work (everything is rooted from changing Locale).
private const val ANDROID_SYSTEM_LOCALE_DATA_PROVIDER_ID = "android_locale"
Expand All @@ -45,7 +45,8 @@ class LocaleController @Inject constructor(
private val languageConfigRetriever: LanguageConfigRetriever,
private val oppiaLogger: OppiaLogger,
private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager,
private val machineLocale: MachineLocale
private val machineLocale: MachineLocale,
private val androidLocaleFactory: AndroidLocaleFactory
) {
private val definitionsLock = ReentrantLock()
private lateinit var supportedLanguages: SupportedLanguages
Expand Down Expand Up @@ -80,7 +81,7 @@ class LocaleController @Inject constructor(

// TODO: this should also work in cases when the process dies.
fun reconstituteDisplayLocale(oppiaLocaleContext: OppiaLocaleContext): DisplayLocale {
return DisplayLocaleImpl(oppiaLocaleContext, machineLocale)
return DisplayLocaleImpl(oppiaLocaleContext, machineLocale, androidLocaleFactory)
}

// TODO: document
Expand Down Expand Up @@ -137,7 +138,6 @@ class LocaleController @Inject constructor(
// consistency by always retrieving the latest state when requested. This does mean locale
// changes can be missed if they aren't accompanied by a configuration change or activity
// recreation.
// TODO: add regex prohibiting this
Locale.setDefault(locale.formattingLocale)
notifyPotentialLocaleChange()
} ?: error("Invalid display locale type passed in: $displayLocale")
Expand Down Expand Up @@ -218,7 +218,7 @@ class LocaleController @Inject constructor(
}

return when (usageMode) {
APP_STRINGS -> DisplayLocaleImpl(localeContext, machineLocale)
APP_STRINGS -> DisplayLocaleImpl(localeContext, machineLocale, androidLocaleFactory)
CONTENT_STRINGS, AUDIO_TRANSLATIONS -> ContentLocale(localeContext)
USAGE_MODE_UNSPECIFIED, UNRECOGNIZED -> null
}
Expand Down
Loading

0 comments on commit 68f6c1d

Please sign in to comment.