Merge pull request #6441 from timurgilfanov/feature-oled-black

Add Black theme option for battery saving on OLED displays
This commit is contained in:
Benoit Marty 2026-04-17 15:41:49 +02:00 committed by GitHub
commit a82e3425be
44 changed files with 293 additions and 50 deletions

View file

@ -54,6 +54,7 @@ import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber
@ -66,6 +67,7 @@ class ElementCallActivity :
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService
@Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
@Inject lateinit var buildMeta: BuildMeta
@ -114,6 +116,7 @@ class ElementCallActivity :
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
@ -57,6 +58,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
@Inject
lateinit var featureFlagService: FeatureFlagService
@Inject
lateinit var enterpriseService: EnterpriseService
@ -88,6 +92,7 @@ class IncomingCallActivity : AppCompatActivity() {
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,

View file

@ -30,6 +30,7 @@ import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.launch
@ -43,6 +44,7 @@ class PinUnlockActivity : AppCompatActivity() {
@Inject lateinit var presenter: PinUnlockPresenter
@Inject lateinit var lockScreenService: LockScreenService
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService
@Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var buildMeta: BuildMeta
@ -56,6 +58,7 @@ class PinUnlockActivity : AppCompatActivity() {
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemAspectRatioBox
import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -49,7 +50,7 @@ fun ProtectedView(
.background(Color(0x99000000)),
contentAlignment = Alignment.Center,
) {
ElementTheme(darkTheme = false, applySystemBarsUpdate = false) {
ElementTheme(theme = Theme.Light, applySystemBarsUpdate = false) {
// Not using a button to be able to have correct size
Text(
modifier = Modifier

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
@ -45,8 +46,11 @@ class AdvancedSettingsPresenter(
val isSharePresenceEnabled by remember {
sessionPreferencesStore.isSharePresenceEnabled()
}.collectAsState(initial = true)
val theme = remember {
appPreferencesStore.getThemeFlow().mapToTheme()
val isBlackThemeAllowed by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.AllowBlackTheme)
}.collectAsState(initial = false)
val theme = remember(isBlackThemeAllowed) {
appPreferencesStore.getThemeFlow().mapToTheme(isBlackThemeAllowed)
}.collectAsState(initial = Theme.System)
val mediaPreviewConfigState = mediaPreviewConfigStateStore.state()
@ -56,6 +60,7 @@ class AdvancedSettingsPresenter(
when (theme.value) {
Theme.System -> ThemeOption.System
Theme.Dark -> ThemeOption.Dark
Theme.Black -> ThemeOption.Black
Theme.Light -> ThemeOption.Light
}
}
@ -65,6 +70,14 @@ class AdvancedSettingsPresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
val availableThemeOptions = remember(isBlackThemeAllowed) {
if (isBlackThemeAllowed) {
ThemeOption.entries
} else {
ThemeOption.entries.filterNot { it == ThemeOption.Black }
}.toImmutableList()
}
val mediaOptimizationState by produceState<MediaOptimizationState?>(null) {
val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality)
combine(
@ -98,6 +111,7 @@ class AdvancedSettingsPresenter(
when (event.theme) {
ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name)
ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name)
ThemeOption.Black -> appPreferencesStore.setTheme(Theme.Black.name)
ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name)
}
}
@ -117,6 +131,7 @@ class AdvancedSettingsPresenter(
isSharePresenceEnabled = isSharePresenceEnabled,
mediaOptimizationState = mediaOptimizationState,
theme = themeOption,
availableThemeOptions = availableThemeOptions,
mediaPreviewConfigState = mediaPreviewConfigState,
eventSink = ::handleEvent,
)

View file

@ -14,12 +14,14 @@ import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.preferences.DropdownOption
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
data class AdvancedSettingsState(
val isDeveloperModeEnabled: Boolean,
val isSharePresenceEnabled: Boolean,
val mediaOptimizationState: MediaOptimizationState?,
val theme: ThemeOption,
val availableThemeOptions: ImmutableList<ThemeOption>,
val mediaPreviewConfigState: MediaPreviewConfigState,
val eventSink: (AdvancedSettingsEvents) -> Unit
)
@ -43,14 +45,22 @@ enum class ThemeOption : DropdownOption {
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_system)
},
Light {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_light)
},
Dark {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_dark)
},
Light {
Black {
@Composable
@ReadOnlyComposable
override fun getText(): String = stringResource(CommonStrings.common_light)
override fun getText(): String = stringResource(CommonStrings.common_black)
}
}

View file

@ -12,6 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
override val values: Sequence<AdvancedSettingsState>
@ -36,6 +38,7 @@ fun aAdvancedSettingsState(
isSharePresenceEnabled: Boolean = false,
mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false),
theme: ThemeOption = ThemeOption.System,
availableThemeOptions: ImmutableList<ThemeOption> = ThemeOption.entries.toImmutableList(),
hideInviteAvatars: Boolean = false,
timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On,
setTimelineMediaPreviewAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@ -46,6 +49,7 @@ fun aAdvancedSettingsState(
isSharePresenceEnabled = isSharePresenceEnabled,
mediaOptimizationState = mediaOptimizationState,
theme = theme,
availableThemeOptions = availableThemeOptions,
mediaPreviewConfigState = MediaPreviewConfigState(
hideInviteAvatars = hideInviteAvatars,
timelineMediaPreviewValue = timelineMediaPreviewValue,

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewBlack
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@ -46,7 +47,6 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toImmutableList
@Composable
fun AdvancedSettingsView(
@ -74,7 +74,7 @@ fun AdvancedSettingsView(
PreferenceDropdown(
title = stringResource(id = CommonStrings.common_appearance),
selectedOption = state.theme,
options = ThemeOption.entries.toImmutableList(),
options = state.availableThemeOptions,
onSelectOption = { themeOption ->
state.eventSink(AdvancedSettingsEvents.SetTheme(themeOption))
}
@ -324,6 +324,11 @@ internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettings
internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
ElementPreviewDark { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun AdvancedSettingsViewBlackPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) =
ElementPreviewBlack { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: AdvancedSettingsState) {

View file

@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -20,6 +21,7 @@ import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -40,6 +42,9 @@ class AdvancedSettingsPresenterTest {
assertThat(isSharePresenceEnabled).isTrue()
assertThat(mediaOptimizationState).isNull()
assertThat(theme).isEqualTo(ThemeOption.System)
assertThat(availableThemeOptions).isEqualTo(
listOf(ThemeOption.System, ThemeOption.Light, ThemeOption.Dark).toImmutableList()
)
assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse()
assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On)
assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized)
@ -204,6 +209,42 @@ class AdvancedSettingsPresenterTest {
}
}
@Test
fun `present - black theme option shown when feature flag enabled`() = runTest {
val presenter = createAdvancedSettingsPresenter(
featureFlagService = FakeFeatureFlagService().apply {
setFeatureEnabled(FeatureFlags.AllowBlackTheme, true)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(availableThemeOptions).contains(ThemeOption.Black)
assertThat(availableThemeOptions).isEqualTo(ThemeOption.entries.toImmutableList())
}
}
}
@Test
fun `present - stored black theme falls back to dark when feature flag disabled`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setTheme(Theme.Black.name)
}
val presenter = createAdvancedSettingsPresenter(appPreferencesStore = appPreferencesStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
with(awaitItem()) {
assertThat(theme).isEqualTo(ThemeOption.Dark)
}
}
}
@Test
fun `present - hide invite avatars`() = runTest {
val mediaPreviewStore = FakeMediaPreviewConfigStateStore()

View file

@ -12,6 +12,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
@ -24,9 +25,11 @@ import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.assertNoNodeWithText
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -65,6 +68,31 @@ class AdvancedSettingsViewTest {
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark))
}
@Test
fun `black theme is shown when available`() {
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
availableThemeOptions = ThemeOption.entries.toImmutableList(),
),
)
rule.clickOn(CommonStrings.common_appearance)
rule.run {
val text = activity.getString(CommonStrings.common_black)
onNodeWithText(text).assertExists()
}
}
@Test
fun `black theme is hidden when unavailable`() {
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(),
),
)
rule.clickOn(CommonStrings.common_appearance)
rule.assertNoNodeWithText(CommonStrings.common_black)
}
@Test
fun `clicking on View source emits the expected event`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()