Remember flows (#4533)

* Add Konsist test to ensure that the result of a function returning a flow is remembered.

* Remember flows before they are collected by state.

* Fix compilation issue

* Make isOnline a val.

* Make selectedUsers() a val.

* Make flow() a val.

* Make getUserConsent(), didAskUserConsent() and getAnalyticsId() some val.

* Remove Timeline.paginationStatus() and replace by direct access to the underlined flow.

* Simplify test

* userConsentFlow must be initialized before because it's used in observeUserConsent

* Fix test compilation
This commit is contained in:
Benoit Marty 2025-04-04 16:50:43 +02:00 committed by GitHub
parent 3e90b3c26d
commit 77a7c0b2e5
52 changed files with 221 additions and 172 deletions

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this) .launchIn(this)
} }
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember { val showSyncSpinner by remember {
derivedStateOf { derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show isOnline && syncIndicator == RoomListService.SyncIndicator.Show

View file

@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
} }
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier -> private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView( LoadingRoomNodeView(
state = LoadingRoomState.Loading, state = LoadingRoomState.Loading,
hasNetworkConnection = isOnline, hasNetworkConnection = isOnline,

View file

@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier -> private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState() val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView( LoadingRoomNodeView(
state = loadingRoomState, state = loadingRoomState,
hasNetworkConnection = isOnline, hasNetworkConnection = isOnline,

View file

@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
@Composable @Composable
override fun present(): AnalyticsPreferencesState { override fun present(): AnalyticsPreferencesState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val isEnabled = analyticsService.getUserConsent() val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
.collectAsState(initial = false)
fun handleEvents(event: AnalyticsOptInEvents) { fun handleEvents(event: AnalyticsOptInEvents) {
when (event) { when (event) {

View file

@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsent().first()).isFalse() assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
assertThat(analyticsService.didAskUserConsent().first()).isTrue() assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.getUserConsent().first()).isTrue() assertThat(analyticsService.userConsentFlow.first()).isTrue()
} }
} }
@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsent().first()).isFalse() assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
assertThat(analyticsService.didAskUserConsent().first()).isTrue() assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.getUserConsent().first()).isFalse() assertThat(analyticsService.userConsentFlow.first()).isFalse()
} }
} }
} }

View file

@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
} }
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine( val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers(), selectedUserListDataStore.selectedUsers,
createRoomConfigFlow, createRoomConfigFlow,
) { selectedUsers, config -> ) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList()) config.copy(invites = selectedUsers.toImmutableList())

View file

@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig()) val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false) val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(initial = false)
val roomAddressValidity = remember { val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown) mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
} }

View file

@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false) val isRoomDirectorySearchEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
}.collectAsState(initial = false)
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {

View file

@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms() recentDirectRooms = matrixClient.getRecentDirectRooms()
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember { var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.Initial()) mutableStateOf(SearchBarResultState.Initial())

View file

@ -8,22 +8,22 @@
package io.element.android.features.createroom.impl.userlist package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject import javax.inject.Inject
class UserListDataStore @Inject constructor() { class UserListDataStore @Inject constructor() {
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList()) private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) { fun selectUser(user: MatrixUser) {
if (!selectedUsers.value.contains(user)) { if (!_selectedUsers.value.contains(user)) {
selectedUsers.tryEmit(selectedUsers.value.plus(user)) _selectedUsers.tryEmit(_selectedUsers.value.plus(user))
} }
} }
fun removeUserFromSelection(user: MatrixUser) { fun removeUserFromSelection(user: MatrixUser) {
selectedUsers.tryEmit(selectedUsers.value.minus(user)) _selectedUsers.tryEmit(_selectedUsers.value.minus(user))
} }
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers val selectedUsers = _selectedUsers.asStateFlow()
} }

View file

@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
moveToNextStepIfNeeded() moveToNextStepIfNeeded()
}) })
analyticsService.didAskUserConsent() analyticsService.didAskUserConsentFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() } .onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)

View file

@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
.onEach { updateState() } .onEach { updateState() }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsent() analyticsService.didAskUserConsentFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { updateState() } .onEach { updateState() }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
} }
private suspend fun needsAnalyticsOptIn(): Boolean { private suspend fun needsAnalyticsOptIn(): Boolean {
return analyticsService.didAskUserConsent().first().not() return analyticsService.didAskUserConsentFlow.first().not()
} }
private suspend fun shouldAskNotificationPermissions(): Boolean { private suspend fun shouldAskNotificationPermissions(): Boolean {

View file

@ -82,7 +82,9 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState { override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) } var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty()) val roomInfo by remember {
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
}.collectAsState(initial = Optional.empty())
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }

View file

@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
@Composable @Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator { override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false) val isBiometricAllowed by remember {
lockScreenStore.isBiometricUnlockAllowed()
}.collectAsState(initial = false)
val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
val isAvailable by remember(lifecycleState) { val isAvailable by remember(lifecycleState) {
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator } derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }

View file

@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
value = !lockScreenConfig.isPinMandatory && hasPinCode value = !lockScreenConfig.isPinMandatory && hasPinCode
} }
} }
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false) val isBiometricEnabled by remember {
lockScreenStore.isBiometricUnlockAllowed()
}.collectAsState(initial = false)
var showRemovePinConfirmation by remember { var showRemovePinConfirmation by remember {
mutableStateOf(false) mutableStateOf(false)
} }

View file

@ -33,9 +33,7 @@ class AccountProviderDataSource @Inject constructor(
defaultAccountProvider defaultAccountProvider
) )
fun flow(): StateFlow<AccountProvider> { val flow: StateFlow<AccountProvider> = accountProvider.asStateFlow()
return accountProvider.asStateFlow()
}
fun reset() { fun reset() {
accountProvider.tryEmit(defaultAccountProvider) accountProvider.tryEmit(defaultAccountProvider)

View file

@ -52,7 +52,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): ConfirmAccountProviderState { override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow().collectAsState() val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember { val loginFlowAction: MutableState<AsyncData<LoginFlow>> = remember {

View file

@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
val parser = Json { ignoreUnknownKeys = true } val parser = Json { ignoreUnknownKeys = true }
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message) val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
val userId = response.userId ?: error("No user ID in response") val userId = response.userId ?: error("No user ID in response")
val homeServer = response.homeServer ?: accountProviderDataSource.flow().value.url val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
val accessToken = response.accessToken ?: error("No access token in response") val accessToken = response.accessToken ?: error("No access token in response")
val deviceId = response.deviceId ?: error("No device ID in response") val deviceId = response.deviceId ?: error("No device ID in response")
return ExternalSession( return ExternalSession(

View file

@ -40,7 +40,7 @@ class LoginPasswordPresenter @Inject constructor(
val formState = rememberSaveable { val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default) mutableStateOf(LoginFormState.Default)
} }
val accountProvider by accountProviderDataSource.flow().collectAsState() val accountProvider by accountProviderDataSource.flow.collectAsState()
fun handleEvents(event: LoginPasswordEvents) { fun handleEvents(event: LoginPasswordEvents) {
when (event) { when (event) {

View file

@ -23,7 +23,7 @@ class AccountProviderDataSourceTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService()) val sut = AccountProviderDataSource(FakeEnterpriseService())
sut.flow().test { sut.flow.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState).isEqualTo( assertThat(initialState).isEqualTo(
AccountProvider( AccountProvider(
@ -43,7 +43,7 @@ class AccountProviderDataSourceTest {
val sut = AccountProviderDataSource(FakeEnterpriseService( val sut = AccountProviderDataSource(FakeEnterpriseService(
defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL } defaultHomeserverResult = { AuthenticationConfig.MATRIX_ORG_URL }
)) ))
sut.flow().test { sut.flow.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState).isEqualTo( assertThat(initialState).isEqualTo(
AccountProvider( AccountProvider(
@ -61,7 +61,7 @@ class AccountProviderDataSourceTest {
@Test @Test
fun `present - user change and reset`() = runTest { fun `present - user change and reset`() = runTest {
val sut = AccountProviderDataSource(FakeEnterpriseService()) val sut = AccountProviderDataSource(FakeEnterpriseService())
sut.flow().test { sut.flow.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER) assertThat(initialState.url).isEqualTo(FakeEnterpriseService.A_FAKE_HOMESERVER)
sut.userSelection(AccountProvider(url = "https://example.com")) sut.userSelection(AccountProvider(url = "https://example.com"))

View file

@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -183,7 +182,7 @@ class MessagesPresenter @AssistedInject constructor(
showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L
} }
} }
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()

View file

@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
mutableStateOf(ActionListState.Target.None) mutableStateOf(ActionListState.Target.None)
} }
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow()
}.collectAsState(initial = false)
val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled() val isPinnedEventsEnabled = isPinnedMessagesFeatureEnabled()
val pinnedEventIds by remember { val pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds } room.roomInfoFlow.map { it.pinnedEventIds }

View file

@ -78,8 +78,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false) val allowCaption by remember {
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false) featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation)
}.collectAsState(initial = false)
val showCaptionCompatibilityWarning by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning)
}.collectAsState(initial = false)
var useSendQueue by remember { mutableStateOf(false) } var useSendQueue by remember { mutableStateOf(false) }
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) } var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }

View file

@ -177,7 +177,9 @@ class MessageComposerPresenter @AssistedInject constructor(
} }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) val sendTypingNotifications by remember {
sessionPreferencesStore.isSendTypingNotificationsEnabled()
}.collectAsState(initial = true)
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) { if (cameraPermissionState.permissionGranted) {
@ -397,16 +399,16 @@ class MessageComposerPresenter @AssistedInject constructor(
.stateIn(this, SharingStarted.Lazily, emptyList()) .stateIn(this, SharingStarted.Lazily, emptyList())
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions -> combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
val result = suggestionsProcessor.process( val result = suggestionsProcessor.process(
suggestion = suggestion, suggestion = suggestion,
roomMembersState = roomMembersState, roomMembersState = roomMembersState,
roomAliasSuggestions = roomAliasSuggestions, roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId, currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention, canSendRoomMention = ::canSendRoomMention,
) )
suggestions.clear() suggestions.clear()
suggestions.addAll(result) suggestions.addAll(result)
} }
.collect() .collect()
} }
} }

View file

@ -109,9 +109,15 @@ class TimelinePresenter @AssistedInject constructor(
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) } val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present() val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val isSendPublicReadReceiptsEnabled by remember {
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true) sessionPreferencesStore.isSendPublicReadReceiptsEnabled()
val isLive by timelineController.isLive().collectAsState(initial = true) }.collectAsState(initial = true)
val renderReadReceipts by remember {
sessionPreferencesStore.isRenderReadReceiptsEnabled()
}.collectAsState(initial = true)
val isLive by remember {
timelineController.isLive()
}.collectAsState(initial = true)
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {

View file

@ -25,7 +25,9 @@ class TimelineProtectionPresenter @Inject constructor(
) : Presenter<TimelineProtectionState> { ) : Presenter<TimelineProtectionState> {
@Composable @Composable
override fun present(): TimelineProtectionState { override fun present(): TimelineProtectionState {
val hideMediaContent by appPreferencesStore.doesHideImagesAndVideosFlow().collectAsState(initial = false) val hideMediaContent by remember {
appPreferencesStore.doesHideImagesAndVideosFlow()
}.collectAsState(initial = false)
var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) } var allowedEvents by remember { mutableStateOf<Set<EventId>>(setOf()) }
val protectionState by remember(hideMediaContent) { val protectionState by remember(hideMediaContent) {
derivedStateOf { derivedStateOf {

View file

@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
) : Presenter<TypingNotificationState> { ) : Presenter<TypingNotificationState> {
@Composable @Composable
override fun present(): TypingNotificationState { override fun present(): TypingNotificationState {
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true) val renderTypingNotifications by remember {
sessionPreferencesStore.isRenderTypingNotificationsEnabled()
}.collectAsState(initial = true)
val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) { val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) {
if (renderTypingNotifications) { if (renderTypingNotifications) {
observeRoomTypingMembers() observeRoomTypingMembers()

View file

@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
@Composable @Composable
override fun present(): MigrationState { override fun present(): MigrationState {
val migrationStoreVersion by migrationStore.applicationMigrationVersion().collectAsState(initial = null) val migrationStoreVersion by remember {
migrationStore.applicationMigrationVersion()
}.collectAsState(initial = null)
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) } var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
// Uncomment this block to run the migration everytime // Uncomment this block to run the migration everytime

View file

@ -40,7 +40,7 @@ class PollHistoryPresenter @Inject constructor(
@Composable @Composable
override fun present(): PollHistoryState { override fun present(): PollHistoryState {
val timeline = room.liveTimeline val timeline = room.liveTimeline
val paginationState by timeline.paginationStatus(Timeline.PaginationDirection.BACKWARDS).collectAsState() val paginationState by timeline.backwardPaginationStatus.collectAsState()
val pollHistoryItemsFlow = remember { val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items -> timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items) pollHistoryItemFactory.create(items)

View file

@ -29,19 +29,18 @@ class AdvancedSettingsPresenter @Inject constructor(
@Composable @Composable
override fun present(): AdvancedSettingsState { override fun present(): AdvancedSettingsState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val isDeveloperModeEnabled by appPreferencesStore val isDeveloperModeEnabled by remember {
.isDeveloperModeEnabledFlow() appPreferencesStore.isDeveloperModeEnabledFlow()
.collectAsState(initial = false) }.collectAsState(initial = false)
val isSharePresenceEnabled by sessionPreferencesStore val isSharePresenceEnabled by remember {
.isSharePresenceEnabled() sessionPreferencesStore.isSharePresenceEnabled()
.collectAsState(initial = true) }.collectAsState(initial = true)
val doesCompressMedia by sessionPreferencesStore val doesCompressMedia by remember {
.doesCompressMedia() sessionPreferencesStore.doesCompressMedia()
.collectAsState(initial = true) }.collectAsState(initial = true)
val theme by remember { val theme by remember {
appPreferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
} }.collectAsState(initial = Theme.System)
.collectAsState(initial = Theme.System)
var showChangeThemeDialog by remember { mutableStateOf(false) } var showChangeThemeDialog by remember { mutableStateOf(false) }
fun handleEvents(event: AdvancedSettingsEvents) { fun handleEvents(event: AdvancedSettingsEvents) {

View file

@ -44,17 +44,17 @@ class BlockedUsersPresenter @Inject constructor(
mutableStateOf(AsyncAction.Uninitialized) mutableStateOf(AsyncAction.Uninitialized)
} }
val renderBlockedUsersDetail = featureFlagService val renderBlockedUsersDetail by remember {
.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails) featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails)
.collectAsState(initial = false) }.collectAsState(initial = false)
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState() val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
val ignoredMatrixUser by produceState( val ignoredMatrixUser by produceState(
initialValue = ignoredUserIds.map { MatrixUser(userId = it) }, initialValue = ignoredUserIds.map { MatrixUser(userId = it) },
key1 = renderBlockedUsersDetail.value, key1 = renderBlockedUsersDetail,
key2 = ignoredUserIds key2 = ignoredUserIds
) { ) {
value = ignoredUserIds.map { value = ignoredUserIds.map {
if (renderBlockedUsersDetail.value) { if (renderBlockedUsersDetail) {
matrixClient.getProfile(it).getOrNull() matrixClient.getProfile(it).getOrNull()
} else { } else {
null null

View file

@ -71,12 +71,14 @@ class DeveloperSettingsPresenter @Inject constructor(
val clearCacheAction = remember { val clearCacheAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
} }
val customElementCallBaseUrl by appPreferencesStore val customElementCallBaseUrl by remember {
.getCustomElementCallBaseUrlFlow() appPreferencesStore
.collectAsState(initial = null) .getCustomElementCallBaseUrlFlow()
val hideImagesAndVideos by appPreferencesStore }.collectAsState(initial = null)
.doesHideImagesAndVideosFlow() val hideImagesAndVideos by remember {
.collectAsState(initial = false) appPreferencesStore
.doesHideImagesAndVideosFlow()
}.collectAsState(initial = false)
val tracingLogLevelFlow = remember { val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) } appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }

View file

@ -58,9 +58,9 @@ class NotificationSettingsPresenter @Inject constructor(
val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val changeNotificationSettingAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val appNotificationsEnabled = userPushStore val appNotificationsEnabled by remember {
.getNotificationEnabledForDevice() userPushStore.getNotificationEnabledForDevice()
.collectAsState(initial = false) }.collectAsState(initial = false)
val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember { val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember {
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized) mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
matrixSettings = matrixSettings.value, matrixSettings = matrixSettings.value,
appSettings = NotificationSettingsState.AppSettings( appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = systemNotificationsEnabled.value, systemNotificationsEnabled = systemNotificationsEnabled.value,
appNotificationsEnabled = appNotificationsEnabled.value appNotificationsEnabled = appNotificationsEnabled,
), ),
changeNotificationSettingAction = changeNotificationSettingAction.value, changeNotificationSettingAction = changeNotificationSettingAction.value,
currentPushDistributor = currentDistributor, currentPushDistributor = currentDistributor,

View file

@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
screenshotHolder.getFileUri() screenshotHolder.getFileUri()
) )
} }
val crashInfo: String by crashDataStore val crashInfo: String by remember {
.crashInfo() crashDataStore.crashInfo()
.collectAsState(initial = "") }.collectAsState(initial = "")
val sendingProgress = remember { val sendingProgress = remember {
mutableFloatStateOf(0f) mutableFloatStateOf(0f)

View file

@ -10,6 +10,7 @@ package io.element.android.features.rageshake.impl.preferences
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -39,13 +40,13 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
mutableStateOf(rageshake.isAvailable()) mutableStateOf(rageshake.isAvailable())
} }
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() } val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
val isEnabled = rageshakeDataStore val isEnabled by remember {
.isEnabled() rageshakeDataStore.isEnabled()
.collectAsState(initial = false) }.collectAsState(initial = false)
val sensitivity = rageshakeDataStore val sensitivity by remember {
.sensitivity() rageshakeDataStore.sensitivity()
.collectAsState(initial = 0f) }.collectAsState(initial = 0f)
fun handleEvents(event: RageshakePreferencesEvents) { fun handleEvents(event: RageshakePreferencesEvents) {
when (event) { when (event) {
@ -56,9 +57,9 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
return RageshakePreferencesState( return RageshakePreferencesState(
isFeatureEnabled = isFeatureAvailable, isFeatureEnabled = isFeatureAvailable,
isEnabled = isEnabled.value, isEnabled = isEnabled,
isSupported = isSupported.value, isSupported = isSupported.value,
sensitivity = sensitivity.value, sensitivity = sensitivity,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -122,7 +122,9 @@ class RoomDetailsPresenter @Inject constructor(
} }
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
val isKnockRequestsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(false) val isKnockRequestsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false)
val knockRequestsCount by produceState<Int?>(null) { val knockRequestsCount by produceState<Int?>(null) {
room.knockRequestsFlow.collect { value = it.size } room.knockRequestsFlow.collect { value = it.size }
} }

View file

@ -50,7 +50,6 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
@ -101,7 +100,7 @@ class RoomListPresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val matrixUser = client.userProfile.collectAsState() val matrixUser = client.userProfile.collectAsState()
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present() val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()

View file

@ -34,7 +34,9 @@ class SignedOutPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): SignedOutState { override fun present(): SignedOutState {
val sessions by sessionStore.sessionsFlow().collectAsState(initial = emptyList()) val sessions by remember {
sessionStore.sessionsFlow()
}.collectAsState(initial = emptyList())
val signedOutSession by remember { val signedOutSession by remember {
derivedStateOf { sessions.firstOrNull { it.userId == sessionId } } derivedStateOf { sessions.firstOrNull { it.userId == sessionId } }
} }

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.matrix.api.sync package io.element.android.libraries.matrix.api.sync
import io.element.android.libraries.core.coroutine.mapState
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface SyncService { interface SyncService {
@ -25,6 +24,6 @@ interface SyncService {
* Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes. * Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes.
*/ */
val syncState: StateFlow<SyncState> val syncState: StateFlow<SyncState>
}
fun SyncService.isOnline(): StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline } val isOnline: StateFlow<Boolean>
}

View file

@ -49,7 +49,10 @@ interface Timeline : AutoCloseable {
val membershipChangeEventReceived: Flow<Unit> val membershipChangeEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean> suspend fun paginate(direction: PaginationDirection): Result<Boolean>
fun paginationStatus(direction: PaginationDirection): StateFlow<PaginationStatus>
val backwardPaginationStatus: StateFlow<PaginationStatus>
val forwardPaginationStatus: StateFlow<PaginationStatus>
val timelineItems: Flow<List<MatrixTimelineItem>> val timelineItems: Flow<List<MatrixTimelineItem>>
suspend fun sendMessage( suspend fun sendMessage(
@ -105,7 +108,7 @@ interface Timeline : AutoCloseable {
caption: String?, caption: String?,
formattedCaption: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
suspend fun sendFile( suspend fun sendFile(
file: File, file: File,

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.sync package io.element.android.libraries.matrix.impl.sync
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -73,4 +74,6 @@ class RustSyncService(
} }
.distinctUntilChanged() .distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }
} }

View file

@ -54,7 +54,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -127,11 +126,11 @@ class RustTimeline(
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode) private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode) private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
private val backPaginationStatus = MutableStateFlow( override val backwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS) Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS)
) )
private val forwardPaginationStatus = MutableStateFlow( override val forwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT) Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT)
) )
@ -167,7 +166,7 @@ class RustTimeline(
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) { private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) { when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.getAndUpdate(update) Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update) Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
} }
} }
@ -185,7 +184,7 @@ class RustTimeline(
} }
}.onFailure { error -> }.onFailure { error ->
if (error is TimelineException.CannotPaginate) { if (error is TimelineException.CannotPaginate) {
Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else { } else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) } updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}")
@ -199,21 +198,14 @@ class RustTimeline(
private fun canPaginate(direction: Timeline.PaginationDirection): Boolean { private fun canPaginate(direction: Timeline.PaginationDirection): Boolean {
if (!isTimelineInitialized.value) return false if (!isTimelineInitialized.value) return false
return when (direction) { return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus.value.canPaginate Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.value.canPaginate
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate
} }
} }
override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow<Timeline.PaginationStatus> {
return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backPaginationStatus
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
}
}
override val timelineItems: Flow<List<MatrixTimelineItem>> = combine( override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
_timelineItems, _timelineItems,
backPaginationStatus, backwardPaginationStatus,
forwardPaginationStatus, forwardPaginationStatus,
matrixRoom.roomInfoFlow.map { it.creator to it.isDm }.distinctUntilChanged(), matrixRoom.roomInfoFlow.map { it.creator to it.isDm }.distinctUntilChanged(),
isTimelineInitialized, isTimelineInitialized,

View file

@ -29,7 +29,7 @@ class DefaultCallWidgetSettingsProvider @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : CallWidgetSettingsProvider { ) : CallWidgetSettingsProvider {
override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings { override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean): MatrixWidgetSettings {
val isAnalyticsEnabled = analyticsService.getUserConsent().first() val isAnalyticsEnabled = analyticsService.userConsentFlow.first()
val options = VirtualElementCallWidgetOptions( val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl, elementCallUrl = baseUrl,
widgetId = widgetId, widgetId = widgetId,

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.test.sync package io.element.android.libraries.matrix.test.sync
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -29,6 +30,8 @@ class FakeSyncService(
override val syncState: StateFlow<SyncState> = syncStateFlow override val syncState: StateFlow<SyncState> = syncStateFlow
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }
suspend fun emitSyncState(syncState: SyncState) { suspend fun emitSyncState(syncState: SyncState) {
syncStateFlow.emit(syncState) syncStateFlow.emit(syncState)
} }

View file

@ -28,19 +28,18 @@ import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File import java.io.File
class FakeTimeline( class FakeTimeline(
private val name: String = "FakeTimeline", private val name: String = "FakeTimeline",
override val timelineItems: Flow<List<MatrixTimelineItem>> = MutableStateFlow(emptyList()), override val timelineItems: Flow<List<MatrixTimelineItem>> = MutableStateFlow(emptyList()),
private val backwardPaginationStatus: MutableStateFlow<Timeline.PaginationStatus> = MutableStateFlow( override val backwardPaginationStatus: MutableStateFlow<Timeline.PaginationStatus> = MutableStateFlow(
Timeline.PaginationStatus( Timeline.PaginationStatus(
isPaginating = false, isPaginating = false,
hasMoreToLoad = true hasMoreToLoad = true
) )
), ),
private val forwardPaginationStatus: MutableStateFlow<Timeline.PaginationStatus> = MutableStateFlow( override val forwardPaginationStatus: MutableStateFlow<Timeline.PaginationStatus> = MutableStateFlow(
Timeline.PaginationStatus( Timeline.PaginationStatus(
isPaginating = false, isPaginating = false,
hasMoreToLoad = false hasMoreToLoad = false
@ -377,13 +376,6 @@ class FakeTimeline(
override suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> = paginateLambda(direction) override suspend fun paginate(direction: Timeline.PaginationDirection): Result<Boolean> = paginateLambda(direction)
override fun paginationStatus(direction: Timeline.PaginationDirection): StateFlow<Timeline.PaginationStatus> {
return when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus
}
}
var loadReplyDetailsLambda: (eventId: EventId) -> InReplyTo = { var loadReplyDetailsLambda: (eventId: EventId) -> InReplyTo = {
InReplyTo.NotLoaded(it) InReplyTo.NotLoaded(it)
} }

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -56,13 +57,13 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
// To reset the store: ResetStore() // To reset the store: ResetStore()
val isAlreadyDenied: Boolean by permissionsStore val isAlreadyDenied: Boolean by remember {
.isPermissionDenied(permission) permissionsStore.isPermissionDenied(permission)
.collectAsState(initial = false) }.collectAsState(initial = false)
val isAlreadyAsked: Boolean by permissionsStore val isAlreadyAsked: Boolean by remember {
.isPermissionAsked(permission) permissionsStore.isPermissionAsked(permission)
.collectAsState(initial = false) }.collectAsState(initial = false)
var permissionState: PermissionState? = null var permissionState: PermissionState? = null

View file

@ -19,9 +19,9 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider>
/** /**
* Return a Flow of Boolean, true if the user has given their consent. * A Flow of Boolean, true if the user has given their consent.
*/ */
fun getUserConsent(): Flow<Boolean> val userConsentFlow: Flow<Boolean>
/** /**
* Update the user consent value. * Update the user consent value.
@ -29,9 +29,9 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
suspend fun setUserConsent(userConsent: Boolean) suspend fun setUserConsent(userConsent: Boolean)
/** /**
* Return a Flow of Boolean, true if the user has been asked for their consent. * A Flow of Boolean, true if the user has been asked for their consent.
*/ */
fun didAskUserConsent(): Flow<Boolean> val didAskUserConsentFlow: Flow<Boolean>
/** /**
* Store the fact that the user has been asked for their consent. * Store the fact that the user has been asked for their consent.
@ -39,9 +39,9 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
suspend fun setDidAskUserConsent() suspend fun setDidAskUserConsent()
/** /**
* Return a Flow of String, used for analytics Id. * A Flow of String, used for analytics Id.
*/ */
fun getAnalyticsId(): Flow<String> val analyticsIdFlow: Flow<String>
/** /**
* Update analyticsId from the AccountData. * Update analyticsId from the AccountData.

View file

@ -43,6 +43,10 @@ class DefaultAnalyticsService @Inject constructor(
// Cache for the properties to send // Cache for the properties to send
private var pendingUserProperties: UserProperties? = null private var pendingUserProperties: UserProperties? = null
override val userConsentFlow: Flow<Boolean> = analyticsStore.userConsentFlow
override val didAskUserConsentFlow: Flow<Boolean> = analyticsStore.didAskUserConsentFlow
override val analyticsIdFlow: Flow<String> = analyticsStore.analyticsIdFlow
init { init {
observeUserConsent() observeUserConsent()
observeSessions() observeSessions()
@ -52,19 +56,11 @@ class DefaultAnalyticsService @Inject constructor(
return analyticsProviders return analyticsProviders
} }
override fun getUserConsent(): Flow<Boolean> {
return analyticsStore.userConsentFlow
}
override suspend fun setUserConsent(userConsent: Boolean) { override suspend fun setUserConsent(userConsent: Boolean) {
Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)") Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)")
analyticsStore.setUserConsent(userConsent) analyticsStore.setUserConsent(userConsent)
} }
override fun didAskUserConsent(): Flow<Boolean> {
return analyticsStore.didAskUserConsentFlow
}
override suspend fun setDidAskUserConsent() { override suspend fun setDidAskUserConsent() {
Timber.tag(analyticsTag.value).d("setDidAskUserConsent()") Timber.tag(analyticsTag.value).d("setDidAskUserConsent()")
analyticsStore.setDidAskUserConsent() analyticsStore.setDidAskUserConsent()
@ -74,10 +70,6 @@ class DefaultAnalyticsService @Inject constructor(
analyticsStore.setDidAskUserConsent(false) analyticsStore.setDidAskUserConsent(false)
} }
override fun getAnalyticsId(): Flow<String> {
return analyticsStore.analyticsIdFlow
}
override suspend fun setAnalyticsId(analyticsId: String) { override suspend fun setAnalyticsId(analyticsId: String) {
Timber.tag(analyticsTag.value).d("setAnalyticsId($analyticsId)") Timber.tag(analyticsTag.value).d("setAnalyticsId($analyticsId)")
analyticsStore.setAnalyticsId(analyticsId) analyticsStore.setAnalyticsId(analyticsId)
@ -93,7 +85,7 @@ class DefaultAnalyticsService @Inject constructor(
} }
private fun observeUserConsent() { private fun observeUserConsent() {
getUserConsent() userConsentFlow
.onEach { consent -> .onEach { consent ->
Timber.tag(analyticsTag.value).d("User consent updated to $consent") Timber.tag(analyticsTag.value).d("User consent updated to $consent")
userConsent.set(consent) userConsent.set(consent)

View file

@ -132,10 +132,10 @@ class DefaultAnalyticsServiceTest {
analyticsStore = store, analyticsStore = store,
) )
assertThat(store.userConsentFlow.first()).isFalse() assertThat(store.userConsentFlow.first()).isFalse()
assertThat(sut.getUserConsent().first()).isFalse() assertThat(sut.userConsentFlow.first()).isFalse()
sut.setUserConsent(true) sut.setUserConsent(true)
assertThat(store.userConsentFlow.first()).isTrue() assertThat(store.userConsentFlow.first()).isTrue()
assertThat(sut.getUserConsent().first()).isTrue() assertThat(sut.userConsentFlow.first()).isTrue()
} }
@Test @Test
@ -146,10 +146,10 @@ class DefaultAnalyticsServiceTest {
analyticsStore = store, analyticsStore = store,
) )
assertThat(store.analyticsIdFlow.first()).isEqualTo("") assertThat(store.analyticsIdFlow.first()).isEqualTo("")
assertThat(sut.getAnalyticsId().first()).isEqualTo("") assertThat(sut.analyticsIdFlow.first()).isEqualTo("")
sut.setAnalyticsId(AN_ID) sut.setAnalyticsId(AN_ID)
assertThat(store.analyticsIdFlow.first()).isEqualTo(AN_ID) assertThat(store.analyticsIdFlow.first()).isEqualTo(AN_ID)
assertThat(sut.getAnalyticsId().first()).isEqualTo(AN_ID) assertThat(sut.analyticsIdFlow.first()).isEqualTo(AN_ID)
} }
@Test @Test
@ -160,10 +160,10 @@ class DefaultAnalyticsServiceTest {
analyticsStore = store, analyticsStore = store,
) )
assertThat(store.didAskUserConsentFlow.first()).isFalse() assertThat(store.didAskUserConsentFlow.first()).isFalse()
assertThat(sut.didAskUserConsent().first()).isFalse() assertThat(sut.didAskUserConsentFlow.first()).isFalse()
sut.setDidAskUserConsent() sut.setDidAskUserConsent()
assertThat(store.didAskUserConsentFlow.first()).isTrue() assertThat(store.didAskUserConsentFlow.first()).isTrue()
assertThat(sut.didAskUserConsent().first()).isTrue() assertThat(sut.didAskUserConsentFlow.first()).isTrue()
} }
@Test @Test

View file

@ -24,11 +24,11 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class NoopAnalyticsService @Inject constructor() : AnalyticsService { class NoopAnalyticsService @Inject constructor() : AnalyticsService {
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet() override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
override fun getUserConsent(): Flow<Boolean> = flowOf(false) override val userConsentFlow: Flow<Boolean> = flowOf(false)
override suspend fun setUserConsent(userConsent: Boolean) = Unit override suspend fun setUserConsent(userConsent: Boolean) = Unit
override fun didAskUserConsent(): Flow<Boolean> = flowOf(true) override val didAskUserConsentFlow: Flow<Boolean> = flowOf(true)
override suspend fun setDidAskUserConsent() = Unit override suspend fun setDidAskUserConsent() = Unit
override fun getAnalyticsId(): Flow<String> = flowOf("") override val analyticsIdFlow: Flow<String> = flowOf("")
override suspend fun setAnalyticsId(analyticsId: String) = Unit override suspend fun setAnalyticsId(analyticsId: String) = Unit
override suspend fun reset() = Unit override suspend fun reset() = Unit
override fun capture(event: VectorAnalyticsEvent) = Unit override fun capture(event: VectorAnalyticsEvent) = Unit

View file

@ -15,6 +15,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeAnalyticsService( class FakeAnalyticsService(
isEnabled: Boolean = false, isEnabled: Boolean = false,
@ -22,7 +23,7 @@ class FakeAnalyticsService(
private val resetLambda: () -> Unit = {}, private val resetLambda: () -> Unit = {},
) : AnalyticsService { ) : AnalyticsService {
private val isEnabledFlow = MutableStateFlow(isEnabled) private val isEnabledFlow = MutableStateFlow(isEnabled)
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) override val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>() val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
val screenEvents = mutableListOf<VectorAnalyticsScreen>() val screenEvents = mutableListOf<VectorAnalyticsScreen>()
val trackedErrors = mutableListOf<Throwable>() val trackedErrors = mutableListOf<Throwable>()
@ -30,19 +31,17 @@ class FakeAnalyticsService(
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet() override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
override fun getUserConsent(): Flow<Boolean> = isEnabledFlow override val userConsentFlow: Flow<Boolean> = isEnabledFlow.asStateFlow()
override suspend fun setUserConsent(userConsent: Boolean) { override suspend fun setUserConsent(userConsent: Boolean) {
isEnabledFlow.value = userConsent isEnabledFlow.value = userConsent
} }
override fun didAskUserConsent(): Flow<Boolean> = didAskUserConsentFlow
override suspend fun setDidAskUserConsent() { override suspend fun setDidAskUserConsent() {
didAskUserConsentFlow.value = true didAskUserConsentFlow.value = true
} }
override fun getAnalyticsId(): Flow<String> = MutableStateFlow("") override val analyticsIdFlow: Flow<String> = MutableStateFlow("")
override suspend fun setAnalyticsId(analyticsId: String) { override suspend fun setAnalyticsId(analyticsId: String) {
} }

View file

@ -0,0 +1,37 @@
/*
* 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.tests.konsist
import androidx.compose.runtime.Composable
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.withAnnotationOf
import com.lemonappdev.konsist.api.verify.assertFalse
import org.junit.Test
class KonsistFlowTest {
@Test
fun `flow must be remembered when it is collected as state`() {
// Match
// ```).collectAsState```
// and
// ```)
// .collectAsState```
val regex = "(.*)\\)(\n\\s*)*\\.collectAsState".toRegex()
Konsist
.scopeFromProject()
.functions()
.withAnnotationOf(Composable::class)
.assertFalse(
additionalMessage = "Please check that the flow is remembered when it is collected as state." +
" Only val flows can be not remembered.",
) { function ->
regex.matches(function.text)
}
}
}