diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index f80db878a7..836a78b398 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -25,6 +28,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.plugin.NodeReadyObserver +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.theme.ElementTheme import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenLockState @@ -61,9 +65,13 @@ class MainActivity : NodeActivity() { @Composable private fun MainContent(appBindings: AppBindings) { val migrationState = appBindings.migrationEntryPoint().present() + val colors by remember { + appBindings.enterpriseService().semanticColorsFlow(sessionId = null) + }.collectAsState(SemanticColorsLightDark.default) ElementThemeApp( appPreferencesStore = appBindings.preferencesStore(), - enterpriseService = appBindings.enterpriseService(), + compoundLight = colors.light, + compoundDark = colors.dark, buildMeta = appBindings.buildMeta() ) { CompositionLocalProvider( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 01f8185e99..9fb04a2314 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.composable.PermanentChild @@ -46,6 +47,8 @@ import io.element.android.appnav.loggedin.SendQueues import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService @@ -67,6 +70,8 @@ import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.waitForNavTargetAttached +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope @@ -81,6 +86,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.ui.common.nodes.emptyNode import io.element.android.services.appnavstate.api.AppNavigationStateService @@ -125,6 +131,9 @@ class LoggedInFlowNode( private val networkMonitor: NetworkMonitor, private val notificationConversationService: NotificationConversationService, private val syncService: SyncService, + private val enterpriseService: EnterpriseService, + private val appPreferencesStore: AppPreferencesStore, + private val buildMeta: BuildMeta, snackbarDispatcher: SnackbarDispatcher, ) : BaseFlowNode( backstack = BackStack( @@ -541,16 +550,26 @@ class LoggedInFlowNode( @Composable override fun View(modifier: Modifier) { - val isOnline by syncService.isOnline.collectAsState() - ConnectivityIndicatorContainer( - isOnline = isOnline, - modifier = modifier, - ) { contentModifier -> - Box(modifier = contentModifier) { - val ftueState by ftueService.state.collectAsState() - BackstackView() - if (ftueState is FtueState.Complete) { - PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ElementThemeApp( + appPreferencesStore = appPreferencesStore, + compoundLight = colors.light, + compoundDark = colors.dark, + buildMeta = buildMeta, + ) { + val isOnline by syncService.isOnline.collectAsState() + ConnectivityIndicatorContainer( + isOnline = isOnline, + modifier = modifier, + ) { contentModifier -> + Box(modifier = contentModifier) { + val ftueState by ftueService.state.collectAsState() + BackstackView() + if (ftueState is FtueState.Complete) { + PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + } } } } diff --git a/enterprise b/enterprise index f662f079f9..dac93821a6 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit f662f079f911b728e5769d10268e2c2775d7287a +Subproject commit dac93821a6f9f9ad1494d3c69c115ef0696eb7ce diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt new file mode 100644 index 0000000000..89a9bfeb19 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt @@ -0,0 +1,18 @@ +/* + * 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.features.call.impl.ui + +import io.element.android.features.call.api.CallType +import io.element.android.libraries.matrix.api.core.SessionId + +fun CallType.getSessionId(): SessionId? { + return when (this) { + is CallType.ExternalUrl -> null + is CallType.RoomCall -> sessionId + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index ae04606d29..44c1c92506 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -23,14 +23,17 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.content.IntentCompat import androidx.core.util.Consumer import androidx.lifecycle.Lifecycle import dev.zacsweers.metro.Inject +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType.ExternalUrl import io.element.android.features.call.impl.DefaultElementCallEntryPoint @@ -105,9 +108,13 @@ class ElementCallActivity : setContent { val pipState = pictureInPicturePresenter.present() ListenToAndroidEvents(pipState) + val colors by remember(webViewTarget.value?.getSessionId()) { + enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId()) + }.collectAsState(SemanticColorsLightDark.default) ElementThemeApp( appPreferencesStore = appPreferencesStore, - enterpriseService = enterpriseService, + compoundLight = colors.light, + compoundDark = colors.dark, buildMeta = buildMeta, ) { val state = presenter.present() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index daf0e26797..34f229b5dc 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -11,9 +11,13 @@ import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.core.content.IntentCompat import androidx.lifecycle.lifecycleScope import dev.zacsweers.metro.Inject +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.call.api.CallType import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.impl.di.CallBindings @@ -78,9 +82,13 @@ class IncomingCallActivity : AppCompatActivity() { val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } if (notificationData != null) { setContent { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = notificationData.sessionId) + }.collectAsState(SemanticColorsLightDark.default) ElementThemeApp( appPreferencesStore = appPreferencesStore, - enterpriseService = enterpriseService, + compoundLight = colors.light, + compoundDark = colors.dark, buildMeta = buildMeta, ) { IncomingCallScreen( diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt new file mode 100644 index 0000000000..3b566784f4 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt @@ -0,0 +1,43 @@ +/* + * 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.features.call.ui + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.ui.getSessionId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import org.junit.Test + +class CallTypeTest { + @Test + fun `getSessionId returns null for ExternalUrl`() { + assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull() + } + + @Test + fun `getSessionId returns the sessionId for RoomCall`() { + assertThat( + CallType.RoomCall( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ).getSessionId() + ).isEqualTo(A_SESSION_ID) + } + + @Test + fun `ExternalUrl stringification does not contain the URL`() { + assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl") + } + + @Test + fun `RoomCall stringification does not contain the URL`() { + assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID).toString()) + .isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID)") + } +} 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 2f62327643..0c855c3a82 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,9 +7,7 @@ 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.colors.SemanticColorsLightDark import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow @@ -26,16 +24,12 @@ interface EnterpriseService { */ suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) - @Composable - fun semanticColorsLight(): State - - @Composable - fun semanticColorsDark(): State + fun semanticColorsFlow(sessionId: SessionId?): Flow fun firebasePushGateway(): String? fun unifiedPushDefaultPushGateway(): String? - val bugReportUrlFlow: Flow + fun bugReportUrlFlow(sessionId: SessionId?): Flow companion object { const val ANY_ACCOUNT_PROVIDER = "*" 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 f2171c4a49..a619c9cf8f 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,19 +7,14 @@ 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 -import io.element.android.compound.tokens.generated.SemanticColors -import io.element.android.compound.tokens.generated.compoundColorsDark -import io.element.android.compound.tokens.generated.compoundColorsLight +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @ContributesBinding(AppScope::class) @@ -34,18 +29,14 @@ class DefaultEnterpriseService : EnterpriseService { override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit - @Composable - override fun semanticColorsLight(): State { - return remember { derivedStateOf { compoundColorsLight } } - } - - @Composable - override fun semanticColorsDark(): State { - return remember { derivedStateOf { compoundColorsDark } } + override fun semanticColorsFlow(sessionId: SessionId?): Flow { + return flowOf(SemanticColorsLightDark.default) } override fun firebasePushGateway(): String? = null override fun unifiedPushDefaultPushGateway(): String? = null - override val bugReportUrlFlow = flowOf(BugReportUrl.UseDefault) + override fun bugReportUrlFlow(sessionId: SessionId?): Flow { + return flowOf(BugReportUrl.UseDefault) + } } 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 5701aa6574..b617f84bdd 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,12 +7,10 @@ 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.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.BugReportUrl 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 @@ -44,28 +42,49 @@ class DefaultEnterpriseServiceTest { } @Test - fun `semanticColorsLight always emits the same value`() = runTest { + fun `semanticColorsFlow always emits the same value`() = runTest { val defaultEnterpriseService = DefaultEnterpriseService() - moleculeFlow(RecompositionMode.Immediate) { - defaultEnterpriseService.semanticColorsLight().value - }.test { + defaultEnterpriseService.semanticColorsFlow(null).test { val initialState = awaitItem() - assertThat(initialState).isEqualTo(compoundColorsLight) - defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "#87654321") - expectNoEvents() + assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) + awaitComplete() } } @Test - fun `semanticColorsDark always emits the same value`() = runTest { + fun `semanticColorsFlow always emits the same value for a session`() = runTest { val defaultEnterpriseService = DefaultEnterpriseService() - moleculeFlow(RecompositionMode.Immediate) { - defaultEnterpriseService.semanticColorsDark().value - }.test { + defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test { val initialState = awaitItem() - assertThat(initialState).isEqualTo(compoundColorsDark) - defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "#87654321") - expectNoEvents() + assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) + awaitComplete() + } + } + + @Test + fun `overrideBrandColor has no effect`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "aColor") + } + + @Test + fun `firebasePushGateway returns null`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.firebasePushGateway()).isNull() + } + + @Test + fun `unifiedPushDefaultPushGateway returns null`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.unifiedPushDefaultPushGateway()).isNull() + } + + @Test + fun `bugReportUrlFlow only emits UseDefault`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.bugReportUrlFlow(A_SESSION_ID).test { + assertThat(awaitItem()).isEqualTo(BugReportUrl.UseDefault) + awaitComplete() } } } 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 64f2898078..2aedd1edbd 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,9 +7,7 @@ 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.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.matrix.api.core.SessionId @@ -24,12 +22,13 @@ class FakeEnterpriseService( private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, private val defaultHomeserverListResult: () -> List = { emptyList() }, private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, - private val semanticColorsLightResult: () -> State = { lambdaError() }, - private val semanticColorsDarkResult: () -> State = { lambdaError() }, + initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, ) : EnterpriseService { + private val semanticColorsState = MutableStateFlow(initialSemanticColors) + override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask { isEnterpriseUserResult(sessionId) } @@ -46,14 +45,8 @@ class FakeEnterpriseService( overrideBrandColorResult(sessionId, brandColor) } - @Composable - override fun semanticColorsLight(): State { - return semanticColorsLightResult() - } - - @Composable - override fun semanticColorsDark(): State { - return semanticColorsDarkResult() + override fun semanticColorsFlow(sessionId: SessionId?): Flow { + return semanticColorsState.asStateFlow() } override fun firebasePushGateway(): String? { @@ -65,5 +58,7 @@ class FakeEnterpriseService( } val bugReportUrlMutableFlow = MutableStateFlow(BugReportUrl.UseDefault) - override val bugReportUrlFlow: Flow = bugReportUrlMutableFlow.asStateFlow() + override fun bugReportUrlFlow(sessionId: SessionId?): Flow { + return bugReportUrlMutableFlow.asStateFlow() + } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt index a494bca8c6..2eacb9fdd0 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt @@ -14,8 +14,12 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.lifecycle.lifecycleScope import dev.zacsweers.metro.Inject +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenService @@ -46,9 +50,13 @@ class PinUnlockActivity : AppCompatActivity() { super.onCreate(savedInstanceState) bindings().inject(this) setContent { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = null) + }.collectAsState(SemanticColorsLightDark.default) ElementThemeApp( appPreferencesStore = appPreferencesStore, - enterpriseService = enterpriseService, + compoundLight = colors.light, + compoundDark = colors.dark, buildMeta = buildMeta, ) { val state = presenter.present() diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index d05ede00ab..43efad9da0 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(projects.features.messages.api) implementation(projects.appconfig) implementation(projects.features.call.api) + implementation(projects.features.enterprise.api) implementation(projects.features.location.api) implementation(projects.features.poll.api) implementation(projects.features.roomcall.api) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt index 1df9969f72..985557c8d8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt @@ -8,6 +8,9 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -15,12 +18,15 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.theme.ForcedDarkElementTheme +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer @@ -31,6 +37,8 @@ class AttachmentsPreviewNode( @Assisted plugins: List, presenterFactory: AttachmentsPreviewPresenter.Factory, private val localMediaRenderer: LocalMediaRenderer, + private val sessionId: SessionId, + private val enterpriseService: EnterpriseService, ) : Node(buildContext, plugins = plugins) { data class Inputs( val attachment: Attachment, @@ -53,7 +61,12 @@ class AttachmentsPreviewNode( @Composable override fun View(modifier: Modifier) { - ForcedDarkElementTheme { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ForcedDarkElementTheme( + colors = colors, + ) { val state = presenter.present() AttachmentsPreviewView( state = state, diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt index 84f8e2ca0a..7148d9e95e 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt @@ -13,7 +13,12 @@ import dev.zacsweers.metro.Inject import io.element.android.appconfig.RageshakeConfig import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.sessionIdFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import okhttp3.HttpUrl @@ -24,17 +29,21 @@ import okhttp3.HttpUrl.Companion.toHttpUrl class DefaultBugReporterUrlProvider( private val bugReportAppNameProvider: BugReportAppNameProvider, private val enterpriseService: EnterpriseService, + private val sessionStore: SessionStore, ) : BugReporterUrlProvider { + @OptIn(ExperimentalCoroutinesApi::class) override fun provide(): Flow { if (bugReportAppNameProvider.provide().isEmpty()) return flowOf(null) - return enterpriseService.bugReportUrlFlow - .map { bugReportUrl -> - when (bugReportUrl) { - is BugReportUrl.Custom -> bugReportUrl.url - BugReportUrl.Disabled -> null - BugReportUrl.UseDefault -> RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() } + return sessionStore.sessionIdFlow().flatMapLatest { sessionId -> + enterpriseService.bugReportUrlFlow(sessionId?.let(::SessionId)) + .map { bugReportUrl -> + when (bugReportUrl) { + is BugReportUrl.Custom -> bugReportUrl.url + BugReportUrl.Disabled -> null + BugReportUrl.UseDefault -> RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() } + } } - } - .map { it?.toHttpUrl() } + .map { it?.toHttpUrl() } + } } } diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt index fb7464adf7..32e38075d8 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt @@ -11,16 +11,19 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.appconfig.RageshakeConfig import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import kotlinx.coroutines.test.runTest import okhttp3.HttpUrl.Companion.toHttpUrl import org.junit.Test class DefaultBugReporterUrlProviderTest { @Test - fun `provide return values when there is an rageshake app name`() = runTest { + fun `provide returns values when there is an rageshake app name`() = runTest { val enterpriseService = FakeEnterpriseService() - val sut = DefaultBugReporterUrlProvider( + val sut = createDefaultBugReporterUrlProvider( bugReportAppNameProvider = { "rageshakeAppName" }, enterpriseService = enterpriseService, ) @@ -36,15 +39,21 @@ class DefaultBugReporterUrlProviderTest { } @Test - fun `provide return null when there is no rageshake app name`() = runTest { - val enterpriseService = FakeEnterpriseService() - val sut = DefaultBugReporterUrlProvider( - bugReportAppNameProvider = { "" }, - enterpriseService = enterpriseService, - ) + fun `provide returns null when there is no rageshake app name`() = runTest { + val sut = createDefaultBugReporterUrlProvider() sut.provide().test { assertThat(awaitItem()).isNull() awaitComplete() } } } + +private fun createDefaultBugReporterUrlProvider( + bugReportAppNameProvider: BugReportAppNameProvider = BugReportAppNameProvider { "" }, + enterpriseService: EnterpriseService = FakeEnterpriseService(), + sessionStore: SessionStore = InMemorySessionStore(), +) = DefaultBugReporterUrlProvider( + bugReportAppNameProvider = bugReportAppNameProvider, + enterpriseService = enterpriseService, + sessionStore = sessionStore, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/colors/SemanticColorsLightDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/colors/SemanticColorsLightDark.kt new file mode 100644 index 0000000000..40667f5009 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/colors/SemanticColorsLightDark.kt @@ -0,0 +1,24 @@ +/* + * 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.compound.colors + +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.compoundColorsDark +import io.element.android.compound.tokens.generated.compoundColorsLight + +data class SemanticColorsLightDark( + val light: SemanticColors, + val dark: SemanticColors, +) { + companion object { + val default = SemanticColorsLightDark( + light = compoundColorsLight, + dark = compoundColorsDark, + ) + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt index cd168713ae..fe9ea18b9c 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import io.element.android.compound.colors.SemanticColorsLightDark /** * Can be used to force a composable in dark theme. @@ -23,6 +24,7 @@ import androidx.compose.ui.graphics.toArgb */ @Composable fun ForcedDarkElementTheme( + colors: SemanticColorsLightDark, lightStatusBar: Boolean = false, content: @Composable () -> Unit, ) { @@ -47,5 +49,11 @@ fun ForcedDarkElementTheme( ) } } - ElementTheme(darkTheme = true, lightStatusBar = lightStatusBar, content = content) + ElementTheme( + darkTheme = true, + compoundLight = colors.light, + compoundDark = colors.dark, + lightStatusBar = lightStatusBar, + content = content, + ) } diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt index 341b7cb650..401fa92fc1 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.screenshot.utils.screenshotFile import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ForcedDarkElementTheme @@ -42,7 +43,9 @@ class ForcedDarkElementThemeTest { verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text(text = "Outside") - ForcedDarkElementTheme { + ForcedDarkElementTheme( + colors = SemanticColorsLightDark.default, + ) { Surface { Box(modifier = Modifier.fillMaxSize()) { Text(text = "Inside ForcedDarkElementTheme", modifier = Modifier.align(Alignment.Center)) diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 3983317055..ff0b4878b2 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -33,7 +33,6 @@ android { implementation(libs.androidx.compose.material3.adaptive) implementation(libs.coil.compose) implementation(libs.vanniktech.blurhash) - implementation(projects.features.enterprise.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) 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 78d02c7f17..adec2348ef 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 @@ -19,7 +19,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.Theme import io.element.android.compound.theme.isDark import io.element.android.compound.theme.mapToTheme -import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -53,7 +53,8 @@ val LocalBuildMeta = staticCompositionLocalOf { @Composable fun ElementThemeApp( appPreferencesStore: AppPreferencesStore, - enterpriseService: EnterpriseService, + compoundLight: SemanticColors, + compoundDark: SemanticColors, buildMeta: BuildMeta, content: @Composable () -> Unit, ) { @@ -70,8 +71,6 @@ fun ElementThemeApp( } ) } - val compoundLight by enterpriseService.semanticColorsLight() - val compoundDark by enterpriseService.semanticColorsDark() CompositionLocalProvider( LocalBuildMeta provides buildMeta, ) { diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index 4af1c54f46..ea435433f4 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) + implementation(projects.features.enterprise.api) implementation(projects.features.viewfolder.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) @@ -54,6 +55,7 @@ dependencies { implementation(projects.libraries.matrix.api) testCommonDependencies(libs, true) + testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.audio.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.featureflag.test) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index cb9743bf97..6599411411 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.mediaviewer.impl.viewer import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -16,13 +19,16 @@ import com.bumble.appyx.core.plugin.plugins import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.theme.ForcedDarkElementTheme +import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.architecture.inputs import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint @@ -47,6 +53,8 @@ class MediaViewerNode( pagerKeysHandler: PagerKeysHandler, private val textFileViewer: TextFileViewer, private val audioFocus: AudioFocus, + private val sessionId: SessionId, + private val enterpriseService: EnterpriseService, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -127,7 +135,12 @@ class MediaViewerNode( @Composable override fun View(modifier: Modifier) { - ForcedDarkElementTheme { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ForcedDarkElementTheme( + colors = colors, + ) { val state = presenter.present() MediaViewerView( state = state, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index 3af2e8cf69..ddc04fc6ce 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -11,10 +11,12 @@ import android.net.Uri import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaviewer.api.MediaInfo @@ -63,6 +65,8 @@ class DefaultMediaViewerEntryPointTest { pagerKeysHandler = PagerKeysHandler(), textFileViewer = { _, _ -> lambdaError() }, audioFocus = FakeAudioFocus(), + sessionId = A_SESSION_ID, + enterpriseService = FakeEnterpriseService(), ) } val callback = object : MediaViewerEntryPoint.Callback { @@ -104,6 +108,8 @@ class DefaultMediaViewerEntryPointTest { pagerKeysHandler = PagerKeysHandler(), textFileViewer = { _, _ -> lambdaError() }, audioFocus = FakeAudioFocus(), + sessionId = A_SESSION_ID, + enterpriseService = FakeEnterpriseService(), ) } val callback = object : MediaViewerEntryPoint.Callback {