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..38992f58ef 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit ffc02b8d0f35188c3ef8a876dc1532bfe3e533da +Subproject commit 38992f58ef472520a2192696c1bbf20e3066191e 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..917ea2eba0 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,12 +37,11 @@ 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 -import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser @@ -220,19 +219,14 @@ internal fun TimelineItemRow( @Composable private fun Modifier.focusedEvent( focusedEventOffset: Dp, - isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild, ): Modifier { - val highlightedLineColor = if (isEnterpriseBuild) { - ElementTheme.colors.textActionAccent - } else { - ElementTheme.colors.borderAccentSubtle - } - val gradientColors = subtleColorStops(isEnterpriseBuild) + val highlightedLineColor = ElementTheme.colors.borderAccentSubtle + 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 { @@ -261,18 +255,3 @@ internal fun FocusedEventPreview() = ElementPreview { .focusedEvent(0.dp), ) } - -@PreviewsDayNight -@Composable -internal fun FocusedEventEnterprisePreview() = ElementPreview { - Box( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .height(160.dp) - .focusedEvent( - focusedEventOffset = 0.dp, - isEnterpriseBuild = true, - ), - ) -} 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..953ed61128 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..7106b53503 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..e00716e2ce 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, @@ -54,7 +58,6 @@ fun DeveloperSettingsView( // Note: this is OK to hardcode strings in this debug screen. PreferenceCategory( title = "Feature flags", - showTopDivider = true, ) { FeatureListContent(state) } @@ -99,7 +102,27 @@ fun DeveloperSettingsView( RageshakePreferencesView( state = state.rageshakeState, ) - PreferenceCategory(title = "Crash", showTopDivider = false) { + if (state.isEnterpriseBuild) { + PreferenceCategory(title = "Theme") { + ListItem( + headlineContent = { + Text("Change brand color") + }, + onClick = { + state.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + } + ) + ListItem( + headlineContent = { + Text("Reset brand color") + }, + onClick = { + state.eventSink(DeveloperSettingsEvents.ChangeBrandColor(null)) + } + ) + } + } + PreferenceCategory(title = "Crash") { ListItem( headlineContent = { Text("Crash the app 💥") @@ -108,7 +131,7 @@ fun DeveloperSettingsView( ) } val cache = state.cacheSize - PreferenceCategory(title = "Cache", showTopDivider = false) { + PreferenceCategory(title = "Cache") { ListItem( headlineContent = { Text("Clear cache") @@ -133,13 +156,25 @@ fun DeveloperSettingsView( ) } } + ColorPickerDialog( + show = state.showColorPicker, + type = ColorPickerType.Classic( + showAlphaBar = false, + ), + onDismissRequest = { + state.eventSink(DeveloperSettingsEvents.SetShowColorPicker(false)) + }, + onPickedColor = { + state.eventSink(DeveloperSettingsEvents.ChangeBrandColor(it)) + }, + ) } @Composable private fun ElementCallCategory( state: DeveloperSettingsState, ) { - PreferenceCategory(title = "Element Call", showTopDivider = true) { + PreferenceCategory(title = "Element Call") { val callUrlState = state.customElementCallBaseUrlState val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) { @@ -189,7 +224,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 f5757e3e72..83b754b757 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -93,6 +93,7 @@ androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constr androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } +androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.0.0" androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" androidx_browser = "androidx.browser:browser:1.9.0" @@ -126,6 +127,7 @@ androidx_compose_material_icons = { module = "androidx.compose.material:material # Coroutines coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines_guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutines" } coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } # Accompanist @@ -203,6 +205,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..c0499c97b4 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt @@ -0,0 +1,45 @@ +/* + * 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 { it.bufferedReader().readText() } + } 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/components/button/GradientFloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt index d58e181353..bb6f55b8ef 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt @@ -37,12 +37,10 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import io.element.android.compound.annotations.CoreColorToken -import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.compound.tokens.generated.internal.LightColorTokens +import io.element.android.libraries.designsystem.colors.gradientActionColors import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.designsystem.theme.components.Icon @OptIn(CoreColorToken::class) @@ -53,26 +51,14 @@ fun GradientFloatingActionButton( shape: Shape = RoundedCornerShape(25), content: @Composable () -> Unit, ) { - val color1 = if (LocalBuildMeta.current.isEnterpriseBuild) { - ElementTheme.colors.textActionAccent - } else { - LightColorTokens.colorGreen700 - } - val color2 = if (LocalBuildMeta.current.isEnterpriseBuild) { - ElementTheme.colors.textActionAccent - } else { - LightColorTokens.colorBlue900 - } + val colors = gradientActionColors() val linearShaderBrush = remember { object : ShaderBrush() { override fun createShader(size: Size): Shader { return LinearGradientShader( from = Offset(size.width, size.height), to = Offset(size.width, 0f), - colors = listOf( - color2, - color1, - ), + colors = colors, ) } } @@ -83,10 +69,7 @@ fun GradientFloatingActionButton( return RadialGradientShader( center = size.center, radius = size.width / 2, - colors = listOf( - color1, - color2, - ) + colors = colors, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt index 90dd64d59d..d53bf61688 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt @@ -40,7 +40,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.colors.gradientActionColors import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.LocalBuildMeta import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue @@ -63,15 +62,7 @@ fun SuperButton( ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp) } } - val colors = if (LocalBuildMeta.current.isEnterpriseBuild) { - listOf( - ElementTheme.colors.textActionAccent, - ElementTheme.colors.textActionAccent, - ) - } else { - gradientActionColors() - } - + val colors = gradientActionColors() val shaderBrush = remember(colors) { object : ShaderBrush() { override fun createShader(size: Size): Shader { 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..941fde0124 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,12 +79,10 @@ class KonsistPreviewTest { "AsyncIndicatorFailurePreview", "AsyncIndicatorLoadingPreview", "BackgroundVerticalGradientDisabledPreview", - "BackgroundVerticalGradientEnterprisePreview", "BackgroundVerticalGradientPreview", "ColorAliasesPreview", "DefaultRoomListTopBarMultiAccountPreview", "DefaultRoomListTopBarWithIndicatorPreview", - "FocusedEventEnterprisePreview", "FocusedEventPreview", "GradientFloatingActionButtonCircleShapePreview", "HeaderFooterPageScrollablePreview", diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FocusedEventEnterprise_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FocusedEventEnterprise_Day_0_en.png deleted file mode 100644 index 581c5d919f..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FocusedEventEnterprise_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6349c9b5e5697b05e8c12896cf5e230edfe09330323c4b1658dfd84d3ebdaa38 -size 9664 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FocusedEventEnterprise_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FocusedEventEnterprise_Night_0_en.png deleted file mode 100644 index f4cf788891..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_FocusedEventEnterprise_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:34bfc1aa893118f9e5f3da63896fcf85f946f0072c460d7f8a4f1d5722b6ec17 -size 9075 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png new file mode 100644 index 0000000000..9f6f9bc434 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77847b759a0795c7c4f6ed529c0d20ffa060983c06196061f3ff61171774d1ab +size 39412 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png new file mode 100644 index 0000000000..2d9b113aa1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dea0cb97b7f8363fb4581da232c2ec6cd5c7ad17a9dcbf5339b275f590e53a8e +size 38794 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Day_0_en.png index 7bb9ad0c60..ca8b99159f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d78695ba9a688d64996fb13ffa27f2870b613141c3bc4ca9ae565fc97de00e1e -size 10522 +oid sha256:45e0eed913d8987940b6745620e229c8ce1659f2603b3bc4a1d19d6753742f6d +size 12394 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Night_0_en.png index ac4762f9c9..39a307604c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35b75a3b70d0b8b5d1279b99ba715150ecf8a8d64f8961c69394a7f7d6292d18 -size 10485 +oid sha256:98302375a306d4a396888cdbaecf358e246f25dcc5e300a2548b84d6fb9c4974 +size 11745 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Day_0_en.png index fb1b6f0d0c..a9ee021ab5 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:454d022bb76f1469ee071bac71d0b2a9061ed2f97390abe591ba17dfb194b98f -size 10576 +oid sha256:52ddea2d8936364fe4b8fcf9c140830cd4354db214cc43f737eaab6070b84c6d +size 12713 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Night_0_en.png index 8df3db1483..0857be997a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components.button_GradientFloatingActionButton_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3c39f527f535195124f0720f598df65665559e91fd6f2dbf35e17f9e71c25f6d -size 10533 +oid sha256:dc4d9958f19470f82c43cc7cc2eb6ae4c0b2ec954a66bd654c76759780c0ae22 +size 11873 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.modifiers_BackgroundVerticalGradientEnterprise_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.modifiers_BackgroundVerticalGradientEnterprise_Day_0_en.png deleted file mode 100644 index f3a0c39c8e..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.modifiers_BackgroundVerticalGradientEnterprise_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f6f581db727c01b084bc60ddb19cfe34e1f097cb91e6ac675c7721021f1d48c4 -size 8446 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.modifiers_BackgroundVerticalGradientEnterprise_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.modifiers_BackgroundVerticalGradientEnterprise_Night_0_en.png deleted file mode 100644 index b9746b8511..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.modifiers_BackgroundVerticalGradientEnterprise_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ad28a3e8f78ccd0263eff3c3d42f506ea4ae54eea88fe81849eac71fbaf0dd05 -size 8458