From 8b703ed04641d1f8fb6584c45d0ebf81e9569abc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 14 Oct 2025 17:13:05 +0200 Subject: [PATCH] Let the enterprise build be able to update the colors. --- app/build.gradle.kts | 1 + enterprise | 2 +- features/enterprise/api/build.gradle.kts | 2 +- .../enterprise/api/EnterpriseService.kt | 15 +++++- .../enterprise/impl-foss/build.gradle.kts | 2 +- .../impl/DefaultEnterpriseService.kt | 16 +++++- .../impl/DefaultEnterpriseServiceTest.kt | 31 +++++++++++ .../enterprise/test/FakeEnterpriseService.kt | 17 ++++-- .../timeline/components/TimelineItemRow.kt | 6 +-- features/preferences/impl/build.gradle.kts | 3 ++ .../impl/developer/DeveloperSettingsEvents.kt | 3 ++ .../developer/DeveloperSettingsPresenter.kt | 16 ++++++ .../impl/developer/DeveloperSettingsState.kt | 2 + .../DeveloperSettingsStateProvider.kt | 8 +++ .../impl/developer/DeveloperSettingsView.kt | 32 ++++++++++- .../DeveloperSettingsPresenterTest.kt | 35 ++++++++++++ gradle/libs.versions.toml | 1 + .../androidutils/assets/AssetReader.kt | 54 +++++++++++++++++++ .../designsystem/modifiers/Gradient.kt | 38 +------------ .../designsystem/theme/ElementThemeApp.kt | 4 +- .../tests/konsist/KonsistPreviewTest.kt | 1 - 21 files changed, 234 insertions(+), 55 deletions(-) create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c7466f680..2019407657 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -324,6 +324,7 @@ licensee { allowUrl("https://jsoup.org/license") allowUrl("https://asm.ow2.io/license.html") allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt") + allowUrl("https://github.com/mhssn95/compose-color-picker/blob/main/LICENSE") ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") // Ignore dependency that are not third-party licenses to us. ignoreDependencies(groupId = "io.element.android") diff --git a/enterprise b/enterprise index ffc02b8d0f..58f37695d2 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit ffc02b8d0f35188c3ef8a876dc1532bfe3e533da +Subproject commit 58f37695d280da85306973586c43d8d63e1c571c diff --git a/features/enterprise/api/build.gradle.kts b/features/enterprise/api/build.gradle.kts index b32f42e31f..7208c71367 100644 --- a/features/enterprise/api/build.gradle.kts +++ b/features/enterprise/api/build.gradle.kts @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 03ecda80c0..5e5e45ffb9 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -7,6 +7,8 @@ package io.element.android.features.enterprise.api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow @@ -17,8 +19,17 @@ interface EnterpriseService { fun defaultHomeserverList(): List suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean - fun semanticColorsLight(): SemanticColors - fun semanticColorsDark(): SemanticColors + /** + * Override the brand color. + * @param brandColor the color in hex format (#RRGGBBAA or #RRGGBB), or null to reset to default. + */ + fun overrideBrandColor(brandColor: String?) + + @Composable + fun semanticColorsLight(): State + + @Composable + fun semanticColorsDark(): State fun firebasePushGateway(): String? fun unifiedPushDefaultPushGateway(): String? diff --git a/features/enterprise/impl-foss/build.gradle.kts b/features/enterprise/impl-foss/build.gradle.kts index c5c194807f..cf3b571be9 100644 --- a/features/enterprise/impl-foss/build.gradle.kts +++ b/features/enterprise/impl-foss/build.gradle.kts @@ -8,7 +8,7 @@ import extension.testCommonDependencies * Please see LICENSE files in the repository root for full details. */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index 4d52e83a8f..6251a0b4e6 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -7,6 +7,10 @@ package io.element.android.features.enterprise.impl +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject @@ -28,9 +32,17 @@ class DefaultEnterpriseService : EnterpriseService { override fun defaultHomeserverList(): List = emptyList() override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true - override fun semanticColorsLight(): SemanticColors = compoundColorsLight + override fun overrideBrandColor(brandColor: String?) = Unit - override fun semanticColorsDark(): SemanticColors = compoundColorsDark + @Composable + override fun semanticColorsLight(): State { + return remember { derivedStateOf { compoundColorsLight } } + } + + @Composable + override fun semanticColorsDark(): State { + return remember { derivedStateOf { compoundColorsDark } } + } override fun firebasePushGateway(): String? = null override fun unifiedPushDefaultPushGateway(): String? = null diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index 9f3ebbb845..d3a4a63ad1 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -7,7 +7,12 @@ package io.element.android.features.enterprise.impl +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.tokens.generated.compoundColorsDark +import io.element.android.compound.tokens.generated.compoundColorsLight import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_SESSION_ID import kotlinx.coroutines.test.runTest @@ -37,4 +42,30 @@ class DefaultEnterpriseServiceTest { val defaultEnterpriseService = DefaultEnterpriseService() assertThat(defaultEnterpriseService.isEnterpriseUser(A_SESSION_ID)).isFalse() } + + @Test + fun `semanticColorsLight always emits the same value`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + moleculeFlow(RecompositionMode.Immediate) { + defaultEnterpriseService.semanticColorsLight().value + }.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(compoundColorsLight) + defaultEnterpriseService.overrideBrandColor("#87654321") + expectNoEvents() + } + } + + @Test + fun `semanticColorsDark always emits the same value`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + moleculeFlow(RecompositionMode.Immediate) { + defaultEnterpriseService.semanticColorsDark().value + }.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(compoundColorsDark) + defaultEnterpriseService.overrideBrandColor("#87654321") + expectNoEvents() + } + } } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index 30ccc4c48d..f2e597c6fa 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -7,6 +7,8 @@ package io.element.android.features.enterprise.test +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService @@ -22,8 +24,9 @@ class FakeEnterpriseService( private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val defaultHomeserverListResult: () -> List = { emptyList() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, - private val semanticColorsLightResult: () -> SemanticColors = { lambdaError() }, - private val semanticColorsDarkResult: () -> SemanticColors = { lambdaError() }, + private val semanticColorsLightResult: () -> State = { lambdaError() }, + private val semanticColorsDarkResult: () -> State = { lambdaError() }, + private val overrideBrandColorResult: (String?) -> Unit = { lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, ) : EnterpriseService { @@ -39,11 +42,17 @@ class FakeEnterpriseService( isAllowedToConnectToHomeserverResult(homeserverUrl) } - override fun semanticColorsLight(): SemanticColors { + override fun overrideBrandColor(brandColor: String?) { + overrideBrandColorResult(brandColor) + } + + @Composable + override fun semanticColorsLight(): State { return semanticColorsLightResult() } - override fun semanticColorsDark(): SemanticColors { + @Composable + override fun semanticColorsDark(): State { return semanticColorsDarkResult() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 119a235cf8..09332ef5c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -37,8 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.libraries.designsystem.colors.gradientSubtleColors import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction -import io.element.android.libraries.designsystem.modifiers.subtleColorStops import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toPx @@ -227,12 +227,12 @@ private fun Modifier.focusedEvent( } else { ElementTheme.colors.borderAccentSubtle } - val gradientColors = subtleColorStops(isEnterpriseBuild) + val gradientColors = gradientSubtleColors() val verticalOffset = focusedEventOffset.toPx() val verticalRatio = 0.7f return drawWithCache { val brush = Brush.verticalGradient( - colorStops = gradientColors, + colors = gradientColors, endY = size.height * verticalRatio, ) onDrawBehind { diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index eb057a9d53..6858ebef51 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) implementation(projects.features.analytics.api) + implementation(projects.features.enterprise.api) implementation(projects.features.licenses.api) implementation(projects.features.logout.api) implementation(projects.features.deactivation.api) @@ -83,6 +84,7 @@ dependencies { implementation(projects.services.toolbox.api) implementation(libs.datetime) implementation(libs.coil.compose) + implementation(libs.color.picker) implementation(libs.androidx.browser) implementation(libs.androidx.datastore.preferences) api(projects.features.preferences.api) @@ -100,6 +102,7 @@ dependencies { testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.features.enterprise.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.logout.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index ced7b8d2b4..cb7a9f5de6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -7,6 +7,7 @@ package io.element.android.features.preferences.impl.developer +import androidx.compose.ui.graphics.Color import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.tracing.TraceLogPack @@ -16,5 +17,7 @@ sealed interface DeveloperSettingsEvents { data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents + data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents + data class ChangeBrandColor(val color: Color) : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index fe7a3461b8..cf2147f5bc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -18,8 +18,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import dev.zacsweers.metro.Inject +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.preferences.impl.developer.tracing.toLogLevel import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase @@ -54,6 +56,7 @@ class DeveloperSettingsPresenter( private val rageshakePresenter: Presenter, private val appPreferencesStore: AppPreferencesStore, private val buildMeta: BuildMeta, + private val enterpriseService: EnterpriseService, ) : Presenter { @Composable override fun present(): DeveloperSettingsState { @@ -71,6 +74,9 @@ class DeveloperSettingsPresenter( val clearCacheAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var showColorPicker by remember { + mutableStateOf(false) + } val customElementCallBaseUrl by remember { appPreferencesStore .getCustomElementCallBaseUrlFlow() @@ -136,6 +142,14 @@ class DeveloperSettingsPresenter( } appPreferencesStore.setTracingLogPacks(currentPacks) } + is DeveloperSettingsEvents.ChangeBrandColor -> { + showColorPicker = false + val color = event.color.value.toHexString(HexFormat.UpperCase).substring(2, 8) + enterpriseService.overrideBrandColor(color) + } + is DeveloperSettingsEvents.SetShowColorPicker -> { + showColorPicker = event.show + } } } @@ -150,6 +164,8 @@ class DeveloperSettingsPresenter( ), tracingLogLevel = tracingLogLevel, tracingLogPacks = tracingLogPacks, + isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + showColorPicker = showColorPicker, eventSink = ::handleEvents ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 93e7b9ae7b..389297a46b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -23,6 +23,8 @@ data class DeveloperSettingsState( val customElementCallBaseUrlState: CustomElementCallBaseUrlState, val tracingLogLevel: AsyncData, val tracingLogPacks: ImmutableList, + val isEnterpriseBuild: Boolean, + val showColorPicker: Boolean, val eventSink: (DeveloperSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index 6ccd857552..e6803d4149 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -28,6 +28,10 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), traceLogPacks: List = emptyList(), + isEnterpriseBuild: Boolean = false, + showColorPicker: Boolean = false, eventSink: (DeveloperSettingsEvents) -> Unit = {}, ) = DeveloperSettingsState( features = aFeatureUiModelList(), @@ -44,6 +50,8 @@ fun aDeveloperSettingsState( customElementCallBaseUrlState = customElementCallBaseUrlState, tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), tracingLogPacks = traceLogPacks.toImmutableList(), + isEnterpriseBuild = isEnterpriseBuild, + showColorPicker = showColorPicker, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 97f08f7a16..bfea30d2b4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType @@ -36,8 +37,11 @@ import io.element.android.libraries.featureflag.ui.FeatureListView import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.ui.strings.CommonStrings +import io.mhssn.colorpicker.ColorPickerDialog +import io.mhssn.colorpicker.ColorPickerType import kotlinx.collections.immutable.toImmutableList +@OptIn(ExperimentalComposeUiApi::class) @Composable fun DeveloperSettingsView( state: DeveloperSettingsState, @@ -99,6 +103,18 @@ fun DeveloperSettingsView( RageshakePreferencesView( state = state.rageshakeState, ) + if (state.isEnterpriseBuild) { + PreferenceCategory(title = "Theme", showTopDivider = false) { + ListItem( + headlineContent = { + Text("Change brand color") + }, + onClick = { + state.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + } + ) + } + } PreferenceCategory(title = "Crash", showTopDivider = false) { ListItem( headlineContent = { @@ -133,6 +149,18 @@ fun DeveloperSettingsView( ) } } + ColorPickerDialog( + show = state.showColorPicker, + type = ColorPickerType.Classic( + showAlphaBar = false, + ), + onDismissRequest = { + state.eventSink(DeveloperSettingsEvents.SetShowColorPicker(false)) + }, + onPickedColor = { + state.eventSink(DeveloperSettingsEvents.ChangeBrandColor(it)) + }, + ) } @Composable @@ -189,7 +217,9 @@ private fun FeatureListContent( @PreviewsDayNight @Composable -internal fun DeveloperSettingsViewPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = ElementPreview { +internal fun DeveloperSettingsViewPreview( + @PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState +) = ElementPreview { DeveloperSettingsView( state = state, onOpenShowkase = {}, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 76b22871f0..6761a37fc6 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -9,7 +9,10 @@ package io.element.android.features.preferences.impl.developer +import androidx.compose.ui.graphics.Color import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase @@ -24,6 +27,8 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -57,6 +62,8 @@ class DeveloperSettingsPresenterTest { assertThat(state.rageshakeState.isSupported).isTrue() assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized) + assertThat(state.isEnterpriseBuild).isFalse() + assertThat(state.showColorPicker).isFalse() } awaitItem().also { state -> assertThat(state.features).isNotEmpty() @@ -170,6 +177,32 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - enterprise build can change the brand color`() = runTest { + val overrideBrandColorResult = lambdaRecorder { } + val presenter = createDeveloperSettingsPresenter( + enterpriseService = FakeEnterpriseService( + isEnterpriseBuild = true, + overrideBrandColorResult = overrideBrandColorResult, + ) + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnterpriseBuild).isTrue() + initialState.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + assertThat(awaitItem().showColorPicker).isTrue() + initialState.eventSink(DeveloperSettingsEvents.SetShowColorPicker(false)) + assertThat(awaitItem().showColorPicker).isFalse() + initialState.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + assertThat(awaitItem().showColorPicker).isTrue() + initialState.eventSink(DeveloperSettingsEvents.ChangeBrandColor(Color.Green)) + assertThat(awaitItem().showColorPicker).isFalse() + overrideBrandColorResult.assertions().isCalledOnce() + .with(value("00FF00")) + } + } + @Test fun `present - won't display features in labs or finished`() = runTest { val availableFeatures = listOf( @@ -219,6 +252,7 @@ class DeveloperSettingsPresenterTest { clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), buildMeta: BuildMeta = aBuildMeta(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( featureFlagService = featureFlagService, @@ -227,6 +261,7 @@ class DeveloperSettingsPresenterTest { rageshakePresenter = { aRageshakePreferencesState() }, appPreferencesStore = preferencesStore, buildMeta = buildMeta, + enterpriseService = enterpriseService, ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8ba007008..e4e713dca9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -203,6 +203,7 @@ opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:2.3.0" haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } +color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.23.0" diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt new file mode 100644 index 0000000000..5de329a754 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.assets + +import android.content.Context +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * Read asset files. + */ +@Inject +class AssetReader( + @ApplicationContext private val context: Context, +) { + private val cache = ConcurrentHashMap() + + /** + * Read an asset from resource and return a String or null in case of error. + * + * @param assetFilename Asset filename + * @return the content of the asset file, or null in case of error + */ + fun readAssetFile(assetFilename: String): String? { + return cache.getOrPut(assetFilename, { + return try { + context.assets.open(assetFilename) + .use { asset -> + buildString { + var ch = asset.read() + while (ch != -1) { + append(ch.toChar()) + ch = asset.read() + } + } + } + } catch (e: Exception) { + Timber.e(e, "## readAssetFile() failed") + null + } + }) + } + + fun clearCache() { + cache.clear() + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt index 5d6f91e4b4..462588403c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt @@ -15,13 +15,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.colors.gradientSubtleColors import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.LocalBuildMeta /** * Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Workspaces-V1?node-id=1141-24692 @@ -30,35 +27,15 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta @Composable fun Modifier.backgroundVerticalGradient( isVisible: Boolean = true, - isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild, ): Modifier { if (!isVisible) return this return background( brush = Brush.verticalGradient( - colorStops = subtleColorStops(isEnterpriseBuild), + colors = gradientSubtleColors(), ), ) } -@Composable -fun subtleColorStops( - isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild, -): Array> { - return buildList { - if (isEnterpriseBuild) { - // For enterprise builds, ensure that we are theming the gradient - add(0f to ElementTheme.colors.textActionAccent.copy(alpha = 0.5f)) - add(0.75f to ElementTheme.colors.bgCanvasDefault) - add(1f to Color.Transparent) - } else { - val colors = gradientSubtleColors() - colors.forEachIndexed { index, color -> - add(index.toFloat() / (colors.size - 1) to color) - } - } - }.toTypedArray() -} - @PreviewsDayNight @Composable internal fun BackgroundVerticalGradientPreview() = ElementPreview { @@ -70,19 +47,6 @@ internal fun BackgroundVerticalGradientPreview() = ElementPreview { ) } -@PreviewsDayNight -@Composable -internal fun BackgroundVerticalGradientEnterprisePreview() = ElementPreview { - Box( - modifier = Modifier - .fillMaxWidth() - .height(height = 100.dp) - .backgroundVerticalGradient( - isEnterpriseBuild = true, - ) - ) -} - @PreviewsDayNight @Composable internal fun BackgroundVerticalGradientDisabledPreview() = ElementPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt index bb0fcd971b..78d02c7f17 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt @@ -70,8 +70,8 @@ fun ElementThemeApp( } ) } - val compoundLight = remember { enterpriseService.semanticColorsLight() } - val compoundDark = remember { enterpriseService.semanticColorsDark() } + val compoundLight by enterpriseService.semanticColorsLight() + val compoundDark by enterpriseService.semanticColorsDark() CompositionLocalProvider( LocalBuildMeta provides buildMeta, ) { diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 082a106be2..bf41ce7933 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -79,7 +79,6 @@ class KonsistPreviewTest { "AsyncIndicatorFailurePreview", "AsyncIndicatorLoadingPreview", "BackgroundVerticalGradientDisabledPreview", - "BackgroundVerticalGradientEnterprisePreview", "BackgroundVerticalGradientPreview", "ColorAliasesPreview", "DefaultRoomListTopBarMultiAccountPreview",