Let the enterprise build be able to update the colors.

This commit is contained in:
Benoit Marty 2025-10-14 17:13:05 +02:00 committed by Benoit Marty
parent 35cf3aeb0b
commit 844e1d2ce5
21 changed files with 234 additions and 55 deletions

View file

@ -324,6 +324,7 @@ licensee {
allowUrl("https://jsoup.org/license") allowUrl("https://jsoup.org/license")
allowUrl("https://asm.ow2.io/license.html") allowUrl("https://asm.ow2.io/license.html")
allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt") 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") ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
// Ignore dependency that are not third-party licenses to us. // Ignore dependency that are not third-party licenses to us.
ignoreDependencies(groupId = "io.element.android") ignoreDependencies(groupId = "io.element.android")

@ -1 +1 @@
Subproject commit ffc02b8d0f35188c3ef8a876dc1532bfe3e533da Subproject commit 58f37695d280da85306973586c43d8d63e1c571c

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {

View file

@ -7,6 +7,8 @@
package io.element.android.features.enterprise.api 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.compound.tokens.generated.SemanticColors
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -17,8 +19,17 @@ interface EnterpriseService {
fun defaultHomeserverList(): List<String> fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean 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<SemanticColors>
@Composable
fun semanticColorsDark(): State<SemanticColors>
fun firebasePushGateway(): String? fun firebasePushGateway(): String?
fun unifiedPushDefaultPushGateway(): String? fun unifiedPushDefaultPushGateway(): String?

View file

@ -8,7 +8,7 @@ import extension.testCommonDependencies
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {

View file

@ -7,6 +7,10 @@
package io.element.android.features.enterprise.impl 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.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
@ -28,9 +32,17 @@ class DefaultEnterpriseService : EnterpriseService {
override fun defaultHomeserverList(): List<String> = emptyList() override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true 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<SemanticColors> {
return remember { derivedStateOf { compoundColorsLight } }
}
@Composable
override fun semanticColorsDark(): State<SemanticColors> {
return remember { derivedStateOf { compoundColorsDark } }
}
override fun firebasePushGateway(): String? = null override fun firebasePushGateway(): String? = null
override fun unifiedPushDefaultPushGateway(): String? = null override fun unifiedPushDefaultPushGateway(): String? = null

View file

@ -7,7 +7,12 @@
package io.element.android.features.enterprise.impl 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 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_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -37,4 +42,30 @@ class DefaultEnterpriseServiceTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = DefaultEnterpriseService()
assertThat(defaultEnterpriseService.isEnterpriseUser(A_SESSION_ID)).isFalse() 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()
}
}
} }

View file

@ -7,6 +7,8 @@
package io.element.android.features.enterprise.test 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.compound.tokens.generated.SemanticColors
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
@ -22,8 +24,9 @@ class FakeEnterpriseService(
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() }, private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
private val semanticColorsLightResult: () -> SemanticColors = { lambdaError() }, private val semanticColorsLightResult: () -> State<SemanticColors> = { lambdaError() },
private val semanticColorsDarkResult: () -> SemanticColors = { lambdaError() }, private val semanticColorsDarkResult: () -> State<SemanticColors> = { lambdaError() },
private val overrideBrandColorResult: (String?) -> Unit = { lambdaError() },
private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
) : EnterpriseService { ) : EnterpriseService {
@ -39,11 +42,17 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl) isAllowedToConnectToHomeserverResult(homeserverUrl)
} }
override fun semanticColorsLight(): SemanticColors { override fun overrideBrandColor(brandColor: String?) {
overrideBrandColorResult(brandColor)
}
@Composable
override fun semanticColorsLight(): State<SemanticColors> {
return semanticColorsLightResult() return semanticColorsLightResult()
} }
override fun semanticColorsDark(): SemanticColors { @Composable
override fun semanticColorsDark(): State<SemanticColors> {
return semanticColorsDarkResult() return semanticColorsDarkResult()
} }

View file

@ -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.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState 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.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.modifiers.subtleColorStops
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.text.toPx
@ -227,12 +227,12 @@ private fun Modifier.focusedEvent(
} else { } else {
ElementTheme.colors.borderAccentSubtle ElementTheme.colors.borderAccentSubtle
} }
val gradientColors = subtleColorStops(isEnterpriseBuild) val gradientColors = gradientSubtleColors()
val verticalOffset = focusedEventOffset.toPx() val verticalOffset = focusedEventOffset.toPx()
val verticalRatio = 0.7f val verticalRatio = 0.7f
return drawWithCache { return drawWithCache {
val brush = Brush.verticalGradient( val brush = Brush.verticalGradient(
colorStops = gradientColors, colors = gradientColors,
endY = size.height * verticalRatio, endY = size.height * verticalRatio,
) )
onDrawBehind { onDrawBehind {

View file

@ -72,6 +72,7 @@ dependencies {
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.licenses.api) implementation(projects.features.licenses.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api) implementation(projects.features.deactivation.api)
@ -83,6 +84,7 @@ dependencies {
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.color.picker)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
api(projects.features.preferences.api) api(projects.features.preferences.api)
@ -100,6 +102,7 @@ dependencies {
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)

View file

@ -7,6 +7,7 @@
package io.element.android.features.preferences.impl.developer 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.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack 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 SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents
data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : 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 data object ClearCache : DeveloperSettingsEvents
} }

View file

@ -18,8 +18,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateMap
import dev.zacsweers.metro.Inject 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.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@ -54,6 +56,7 @@ class DeveloperSettingsPresenter(
private val rageshakePresenter: Presenter<RageshakePreferencesState>, private val rageshakePresenter: Presenter<RageshakePreferencesState>,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@Composable @Composable
override fun present(): DeveloperSettingsState { override fun present(): DeveloperSettingsState {
@ -71,6 +74,9 @@ class DeveloperSettingsPresenter(
val clearCacheAction = remember { val clearCacheAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
} }
var showColorPicker by remember {
mutableStateOf(false)
}
val customElementCallBaseUrl by remember { val customElementCallBaseUrl by remember {
appPreferencesStore appPreferencesStore
.getCustomElementCallBaseUrlFlow() .getCustomElementCallBaseUrlFlow()
@ -136,6 +142,14 @@ class DeveloperSettingsPresenter(
} }
appPreferencesStore.setTracingLogPacks(currentPacks) 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, tracingLogLevel = tracingLogLevel,
tracingLogPacks = tracingLogPacks, tracingLogPacks = tracingLogPacks,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
showColorPicker = showColorPicker,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -23,6 +23,8 @@ data class DeveloperSettingsState(
val customElementCallBaseUrlState: CustomElementCallBaseUrlState, val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val tracingLogLevel: AsyncData<LogLevelItem>, val tracingLogLevel: AsyncData<LogLevelItem>,
val tracingLogPacks: ImmutableList<TraceLogPack>, val tracingLogPacks: ImmutableList<TraceLogPack>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit val eventSink: (DeveloperSettingsEvents) -> Unit
) )

View file

@ -28,6 +28,10 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
baseUrl = "https://call.element.ahoy", baseUrl = "https://call.element.ahoy",
) )
), ),
aDeveloperSettingsState(
isEnterpriseBuild = true,
showColorPicker = true,
),
) )
} }
@ -35,6 +39,8 @@ fun aDeveloperSettingsState(
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized, clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
traceLogPacks: List<TraceLogPack> = emptyList(), traceLogPacks: List<TraceLogPack> = emptyList(),
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
eventSink: (DeveloperSettingsEvents) -> Unit = {}, eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState( ) = DeveloperSettingsState(
features = aFeatureUiModelList(), features = aFeatureUiModelList(),
@ -44,6 +50,8 @@ fun aDeveloperSettingsState(
customElementCallBaseUrlState = customElementCallBaseUrlState, customElementCallBaseUrlState = customElementCallBaseUrlState,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
tracingLogPacks = traceLogPacks.toImmutableList(), tracingLogPacks = traceLogPacks.toImmutableList(),
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType 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.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.mhssn.colorpicker.ColorPickerDialog
import io.mhssn.colorpicker.ColorPickerType
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun DeveloperSettingsView( fun DeveloperSettingsView(
state: DeveloperSettingsState, state: DeveloperSettingsState,
@ -99,6 +103,18 @@ fun DeveloperSettingsView(
RageshakePreferencesView( RageshakePreferencesView(
state = state.rageshakeState, 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) { PreferenceCategory(title = "Crash", showTopDivider = false) {
ListItem( ListItem(
headlineContent = { 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 @Composable
@ -189,7 +217,9 @@ private fun FeatureListContent(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun DeveloperSettingsViewPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = ElementPreview { internal fun DeveloperSettingsViewPreview(
@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState
) = ElementPreview {
DeveloperSettingsView( DeveloperSettingsView(
state = state, state = state,
onOpenShowkase = {}, onOpenShowkase = {},

View file

@ -9,7 +9,10 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.graphics.Color
import com.google.common.truth.Truth.assertThat 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.developer.tracing.LogLevelItem
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase 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.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule 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 io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -57,6 +62,8 @@ class DeveloperSettingsPresenterTest {
assertThat(state.rageshakeState.isSupported).isTrue() assertThat(state.rageshakeState.isSupported).isTrue()
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized) assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized)
assertThat(state.isEnterpriseBuild).isFalse()
assertThat(state.showColorPicker).isFalse()
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.features).isNotEmpty() assertThat(state.features).isNotEmpty()
@ -170,6 +177,32 @@ class DeveloperSettingsPresenterTest {
} }
} }
@Test
fun `present - enterprise build can change the brand color`() = runTest {
val overrideBrandColorResult = lambdaRecorder<String?, Unit> { }
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 @Test
fun `present - won't display features in labs or finished`() = runTest { fun `present - won't display features in labs or finished`() = runTest {
val availableFeatures = listOf( val availableFeatures = listOf(
@ -219,6 +252,7 @@ class DeveloperSettingsPresenterTest {
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
): DeveloperSettingsPresenter { ): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter( return DeveloperSettingsPresenter(
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
@ -227,6 +261,7 @@ class DeveloperSettingsPresenterTest {
rageshakePresenter = { aRageshakePreferencesState() }, rageshakePresenter = { aRageshakePreferencesState() },
appPreferencesStore = preferencesStore, appPreferencesStore = preferencesStore,
buildMeta = buildMeta, buildMeta = buildMeta,
enterpriseService = enterpriseService,
) )
} }
} }

View file

@ -203,6 +203,7 @@ opusencoder = "io.element.android:opusencoder:1.2.0"
zxing_cpp = "io.github.zxing-cpp:android:2.3.0" zxing_cpp = "io.github.zxing-cpp:android:2.3.0"
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.23.0" posthog = "com.posthog:posthog-android:3.23.0"

View file

@ -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<String, String?>()
/**
* 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()
}
}

View file

@ -15,13 +15,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp 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.colors.gradientSubtleColors
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 * 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 @Composable
fun Modifier.backgroundVerticalGradient( fun Modifier.backgroundVerticalGradient(
isVisible: Boolean = true, isVisible: Boolean = true,
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Modifier { ): Modifier {
if (!isVisible) return this if (!isVisible) return this
return background( return background(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colorStops = subtleColorStops(isEnterpriseBuild), colors = gradientSubtleColors(),
), ),
) )
} }
@Composable
fun subtleColorStops(
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Array<Pair<Float, Color>> {
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 @PreviewsDayNight
@Composable @Composable
internal fun BackgroundVerticalGradientPreview() = ElementPreview { 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 @PreviewsDayNight
@Composable @Composable
internal fun BackgroundVerticalGradientDisabledPreview() = ElementPreview { internal fun BackgroundVerticalGradientDisabledPreview() = ElementPreview {

View file

@ -70,8 +70,8 @@ fun ElementThemeApp(
} }
) )
} }
val compoundLight = remember { enterpriseService.semanticColorsLight() } val compoundLight by enterpriseService.semanticColorsLight()
val compoundDark = remember { enterpriseService.semanticColorsDark() } val compoundDark by enterpriseService.semanticColorsDark()
CompositionLocalProvider( CompositionLocalProvider(
LocalBuildMeta provides buildMeta, LocalBuildMeta provides buildMeta,
) { ) {

View file

@ -79,7 +79,6 @@ class KonsistPreviewTest {
"AsyncIndicatorFailurePreview", "AsyncIndicatorFailurePreview",
"AsyncIndicatorLoadingPreview", "AsyncIndicatorLoadingPreview",
"BackgroundVerticalGradientDisabledPreview", "BackgroundVerticalGradientDisabledPreview",
"BackgroundVerticalGradientEnterprisePreview",
"BackgroundVerticalGradientPreview", "BackgroundVerticalGradientPreview",
"ColorAliasesPreview", "ColorAliasesPreview",
"DefaultRoomListTopBarMultiAccountPreview", "DefaultRoomListTopBarMultiAccountPreview",