Skip to content

In-app language preferences #2850

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@
android:resource="@xml/file_providers" />
</provider>

<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>

</application>

</manifest>
1 change: 1 addition & 0 deletions changelog.d/2850.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added in-app language preferences
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsN
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode
import io.element.android.features.preferences.impl.language.LanguageSettingsNode
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode
Expand Down Expand Up @@ -88,6 +89,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object NotificationSettings : NavTarget

@Parcelize
data object LanguageSettings : NavTarget

@Parcelize
data object TroubleshootNotifications : NavTarget

Expand Down Expand Up @@ -135,6 +139,10 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.NotificationSettings)
}

override fun onOpenLanguageSettings() {
backstack.push(NavTarget.LanguageSettings)
}

override fun onOpenLockScreenSettings() {
backstack.push(NavTarget.LockScreenSettings)
}
Expand Down Expand Up @@ -204,6 +212,9 @@ class PreferencesFlowNode @AssistedInject constructor(
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input, callback))
}
NavTarget.LanguageSettings -> {
createNode<LanguageSettingsNode>(buildContext)
}
NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.features.preferences.impl.language

import java.util.Locale

sealed interface LanguageSettingsEvents {
data class SetLocale(val locale: Locale) : LanguageSettingsEvents
data object SetToDefault : LanguageSettingsEvents
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.features.preferences.impl.language

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope

@ContributesNode(SessionScope::class)
class LanguageSettingsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LanguageSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LanguageSettingsView(
state = state,
modifier = modifier,
onBackPressed = ::navigateUp
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.features.preferences.impl.language

import android.annotation.SuppressLint
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.os.LocaleListCompat
import io.element.android.libraries.architecture.Presenter
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
import javax.inject.Inject

class LanguageSettingsPresenter @Inject constructor() : Presenter<LanguageSettingsState> {

Check warning on line 34 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L34

Added line #L34 was not covered by tests
@Composable
override fun present(): LanguageSettingsState {
val context = LocalContext.current

Check warning on line 37 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L36-L37

Added lines #L36 - L37 were not covered by tests

val supportedLocales by remember { mutableStateOf(parseLocaleConfig(context)) }
var selectedLocale by remember { mutableStateOf(AppCompatDelegate.getApplicationLocales()) }

Check warning on line 40 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L39-L40

Added lines #L39 - L40 were not covered by tests

fun handleEvents(event: LanguageSettingsEvents) {
when (event) {

Check warning on line 43 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L43

Added line #L43 was not covered by tests
is LanguageSettingsEvents.SetLocale -> {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.create(event.locale))
selectedLocale = AppCompatDelegate.getApplicationLocales()

Check warning on line 46 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L45-L46

Added lines #L45 - L46 were not covered by tests
}
LanguageSettingsEvents.SetToDefault -> {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
selectedLocale = AppCompatDelegate.getApplicationLocales()

Check warning on line 50 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L49-L50

Added lines #L49 - L50 were not covered by tests
}
}
}

return LanguageSettingsState(
supportedLocales = supportedLocales,
selectedLocale = selectedLocale,
eventSink = { handleEvents(it) }

Check warning on line 58 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L56-L58

Added lines #L56 - L58 were not covered by tests
)
}

// Since there is no androidx compatibility version of LocaleConfig, manual parsing of the resource file is needed
@SuppressLint("DiscouragedApi") // We don't have access to the R values of :app here, which is why Resources.getIdentifier() is used
private fun parseLocaleConfig(context: Context): List<Locale> {
val list = mutableListOf<Locale>()

Check warning on line 65 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L65

Added line #L65 was not covered by tests

val resources = context.resources
val xml = resources.getXml(resources.getIdentifier("locales_config", "xml", context.packageName))

Check warning on line 68 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L67-L68

Added lines #L67 - L68 were not covered by tests

while (xml.eventType != XmlPullParser.END_DOCUMENT) {
when (xml.eventType) {

Check warning on line 71 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L71

Added line #L71 was not covered by tests
XmlPullParser.START_TAG -> {
if (xml.name == "locale") {
val tag = xml.getAttributeValue("http://schemas.android.com/apk/res/android", "name")
list.add(Locale.forLanguageTag(tag))

Check warning on line 75 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L74-L75

Added lines #L74 - L75 were not covered by tests
}
}
}
xml.next()

Check warning on line 79 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L79

Added line #L79 was not covered by tests
}
xml.close()

Check warning on line 81 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L81

Added line #L81 was not covered by tests

return list.toList()

Check warning on line 83 in features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt

View check run for this annotation

Codecov / codecov/patch

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/language/LanguageSettingsPresenter.kt#L83

Added line #L83 was not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.features.preferences.impl.language

import androidx.core.os.LocaleListCompat
import java.util.Locale

data class LanguageSettingsState(
val supportedLocales: List<Locale>,
val selectedLocale: LocaleListCompat,
val eventSink: (LanguageSettingsEvents) -> Unit,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.features.preferences.impl.language

import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.os.LocaleListCompat
import java.util.Locale

open class LanguageSettingsStateProvider : PreviewParameterProvider<LanguageSettingsState> {
override val values: Sequence<LanguageSettingsState>
get() = sequenceOf(
aLanguageSettingsState(),
aLanguageSettingsState(LocaleListCompat.forLanguageTags("en")),
aLanguageSettingsState(LocaleListCompat.forLanguageTags("zh-TW"))
)
}

fun aLanguageSettingsState(
selectedLocale: LocaleListCompat = LocaleListCompat.getEmptyLocaleList(),
eventSink: (LanguageSettingsEvents) -> Unit = {}
) = LanguageSettingsState(
supportedLocales = listOf(
Locale.forLanguageTag("en"),
Locale.forLanguageTag("de"),
Locale.forLanguageTag("fr"),
Locale.forLanguageTag("zh-CN"),
Locale.forLanguageTag("zh-TW")
),
selectedLocale = selectedLocale,
eventSink = eventSink
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.features.preferences.impl.language

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
import java.util.Locale

@Composable
fun LanguageSettingsView(
state: LanguageSettingsState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
PreferencePage(
modifier = modifier,
onBackPressed = onBackPressed,
title = stringResource(id = CommonStrings.common_language_settings)
) {
RadioButtonListItem(
headline = stringResource(id = R.string.screen_language_settings_system_default),
selected = state.selectedLocale.isEmpty,
onSelected = { state.eventSink(LanguageSettingsEvents.SetToDefault) }
)
state.supportedLocales.forEach { locale ->
RadioButtonListItem(
headline = locale.displayName,
selected = localeMatches(locale, state.selectedLocale.get(0)),
onSelected = { state.eventSink(LanguageSettingsEvents.SetLocale(locale)) },
)
}
}
}

fun localeMatches(supportedLocale: Locale, selectedLocale: Locale?): Boolean {
if (selectedLocale == null) return false
if (supportedLocale.language != selectedLocale.language) return false
// Do not attempt to match the country (which can be set from the system settings) if the supported version doesn't define one
if (supportedLocale.country.isEmpty()) return true
return supportedLocale.country == selectedLocale.country
}

@PreviewsDayNight
@Composable
internal fun LanguageSettingsViewPreview(@PreviewParameter(LanguageSettingsStateProvider::class) state: LanguageSettingsState) =
ElementPreview {
LanguageSettingsView(state = state, onBackPressed = { })
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenAbout()
fun onOpenDeveloperSettings()
fun onOpenNotificationSettings()
fun onOpenLanguageSettings()
fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
Expand Down Expand Up @@ -105,6 +106,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}

private fun onOpenLanguageSettings() {
plugins<Callback>().forEach { it.onOpenLanguageSettings() }
}

private fun onOpenLockScreenSettings() {
plugins<Callback>().forEach { it.onOpenLockScreenSettings() }
}
Expand Down Expand Up @@ -138,6 +143,7 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenLanguageSettings = this::onOpenLanguageSettings,
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
onOpenUserProfile = this::onOpenUserProfile,
onOpenBlockedUsers = this::onOpenBlockedUsers,
Expand Down
Loading
Loading