Merge branch 'develop' into feature/fga/advanced_settings_moderation_and_safety

This commit is contained in:
ganfra 2025-04-10 18:32:04 +02:00
commit 322dd9a945
278 changed files with 2632 additions and 1649 deletions

View file

@ -1,6 +1,6 @@
name: Pull Request
on:
pull_request:
pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call:
secrets:

View file

@ -1,3 +1,71 @@
Changes in Element X v25.04.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.04.0 -->
## What's Changed
### ✨ Features
* Enable Rust trace log packs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4514
* Allow using a hardware keyboard to unlock the app using a pin code by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4530
### 🙌 Improvements
* Change (mention span) : rework and add more cases by @ganfra in https://github.com/element-hq/element-x-android/pull/4476
* Add kick (remove) confirmation and reason by @bmarty in https://github.com/element-hq/element-x-android/pull/4507
* Remove the green badge on a pending invite after a first preview by @bmarty in https://github.com/element-hq/element-x-android/pull/4532
### 🐛 Bugfixes
* Improve touch indicators for the user info UI in the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4482
* Limit the text length in the 'in reply to' preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4491
* Timeline header: ensure that the decoration is clickable by @bmarty in https://github.com/element-hq/element-x-android/pull/4495
* Add video autoplay to media gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4499
* Add `WakeLock` to dismiss ringing call screen when call is cancelled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4478
* Make sure the live timeline is destroyed before clearing a room's cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4515
* Fix bullet points not having leading margin on timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4536
* Fix the share location URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4544
* Add a inderminate progress bar when loging out and in Waiting state. by @bmarty in https://github.com/element-hq/element-x-android/pull/4538
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4506
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4543
### 🧱 Build
* Element config by @bmarty in https://github.com/element-hq/element-x-android/pull/4471
* Check if Manifest.permission.REQUEST_INSTALL_PACKAGES is in the manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/4490
* Remove nightly_enterprise.yml. by @bmarty in https://github.com/element-hq/element-x-android/pull/4492
* Log the packageId which is currently built. by @bmarty in https://github.com/element-hq/element-x-android/pull/4494
* Use handy buildConfigFieldStr. by @bmarty in https://github.com/element-hq/element-x-android/pull/4501
* Fix warnings in InMemoryAppPreferencesStore by @bmarty in https://github.com/element-hq/element-x-android/pull/4523
### Dependency upgrades
* fix(deps): update camera to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4483
* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4487
* fix(deps): update dependency com.posthog:posthog-android to v3.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4469
* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4484
* fix(deps): update dependencyanalysis to v2.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4493
* fix(deps): update media3 to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4488
* fix(deps): update dependency io.element.android:element-call-embedded to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4498
* fix(deps): update dependency com.google.firebase:firebase-bom to v33.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4508
* fix(deps): update dependency com.posthog:posthog-android to v3.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4516
* fix(deps): update dependency io.sentry:sentry-android to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4509
* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4444
* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4522
* fix(deps): update dependencyanalysis to v2.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4527
* fix(deps): update dependency io.element.android:compound-android to v25.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4537
* chore(deps): update plugin dependencycheck to v12.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4540
* fix(deps): update appyx to v1.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4547
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4548
### Others
* Update screenshots by @bmarty in https://github.com/element-hq/element-x-android/pull/4497
* Update store description. by @bmarty in https://github.com/element-hq/element-x-android/pull/4496
* Improve TextFieldDialog by @bmarty in https://github.com/element-hq/element-x-android/pull/4512
* Make `RustMatrixClient.close` asynchronous by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4513
* Replace OutlinedTextField by our TextField by @bmarty in https://github.com/element-hq/element-x-android/pull/4521
* Remove alias from room invite item by @bmarty in https://github.com/element-hq/element-x-android/pull/4531
* Remember flows by @bmarty in https://github.com/element-hq/element-x-android/pull/4533
* Use colors from compound for badges by @bmarty in https://github.com/element-hq/element-x-android/pull/4545
* Update app icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4534
* Click on userId / room alias to copy value to clipboard. by @bmarty in https://github.com/element-hq/element-x-android/pull/4549
* Run the 'prevent blocked' workflow even if PR has conflicts by @robintown in https://github.com/element-hq/element-x-android/pull/4432
* Update wording for push provider support test. (#4079) by @bmarty in https://github.com/element-hq/element-x-android/pull/4553
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.4...v25.04.0
Changes in Element X v25.03.4
=============================

View file

@ -5,8 +5,8 @@
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<!-- Must be equal to DarkDesignTokens.colorThemeBg -->
<!-- Must be equal to DarkColorTokens.colorThemeBg -->
<color name="splashscreen_bg_dark">#FF101317</color>
<!-- Must be equal to LightDesignTokens.colorThemeBg -->
<!-- Must be equal to LightColorTokens.colorThemeBg -->
<color name="splashscreen_bg_light">#FFFFFFFF</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

View file

@ -10,25 +10,28 @@ package io.element.android.appicon.element
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
internal fun IconPreview() {
Box {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
Image(
modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
)
Image(
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
contentDescription = null,
)
}
}
@ -36,8 +39,15 @@ internal fun IconPreview() {
@Composable
internal fun RoundIconPreview() {
Box(modifier = Modifier.clip(shape = CircleShape)) {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
Image(
modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
)
Image(
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
contentDescription = null,
)
}
}
@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
internal fun MonochromeIconPreview() {
Box(
modifier = Modifier
.size(108.dp)
.background(Color(0xFF2F3133))
.clip(shape = RoundedCornerShape(32.dp)),
contentAlignment = Alignment.Center
.background(Color(0xFF2F3133)),
) {
Image(
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -1,2 +1,10 @@
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_launcher_background" />
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#010302"
android:fillType="evenOdd"
android:pathData="m0,0h108v108h-108z" />
</vector>

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.sync.SlidingSyncVersion
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.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
derivedStateOf {
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.alias.ResolvedRoomAlias
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.distinctUntilChanged
import kotlinx.coroutines.flow.first
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
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.matrix.api.core.RoomId
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.launchIn
import kotlinx.coroutines.flow.map
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
hasNetworkConnection = isOnline,

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
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 {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}

View file

@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
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) {
when (event) {

View file

@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
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 searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.Initial())

View file

@ -8,22 +8,22 @@
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class UserListDataStore @Inject constructor() {
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
if (!selectedUsers.value.contains(user)) {
selectedUsers.tryEmit(selectedUsers.value.plus(user))
if (!_selectedUsers.value.contains(user)) {
_selectedUsers.tryEmit(_selectedUsers.value.plus(user))
}
}
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()
})
analyticsService.didAskUserConsent()
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope)

View file

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

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.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
interface SeenInvitesStore {
/**
* Returns a flow of seen room IDs of invitation.
*/
fun seenRoomIds(): Flow<Set<RoomId>>
/**
* Mark the invitation as seen.
* Call this when the invitation details are shown to the user.
* @param roomId the room ID of the invitation to mark as seen.
*/
suspend fun markAsSeen(roomId: RoomId)
/**
* Mark the invitation as unseen.
* Call this when the invitation has been accepted or declined.
* @param roomId the room ID of the invitation to mark as unseen.
*/
suspend fun markAsUnSeen(roomId: RoomId)
/**
* Delete the store.
*/
suspend fun clear()
}

View file

@ -21,6 +21,7 @@ setupAnvil()
dependencies {
api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@ -35,6 +36,7 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)

View file

@ -0,0 +1,90 @@
/*
* 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.invite.impl
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class)
class DefaultSeenInvitesStore @Inject constructor(
@ApplicationContext context: Context,
currentSessionIdHolder: CurrentSessionIdHolder,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
sessionObserver: SessionObserver,
) : SeenInvitesStore {
private val sessionId: SessionId = currentSessionIdHolder.current
init {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionCreated(userId: String) = Unit
override suspend fun onSessionDeleted(userId: String) {
if (sessionId.value == userId) {
clear()
}
}
})
}
private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
}
private val store = PreferenceDataStoreFactory.create(
scope = sessionCoroutineScope,
migrations = emptyList(),
) {
dataStoreFile
}
override fun seenRoomIds(): Flow<Set<RoomId>> =
store.data.map { prefs ->
prefs[seenInvitesKey]
.orEmpty()
.map { RoomId(it) }
.toSet()
}
override suspend fun markAsSeen(roomId: RoomId) {
store.edit { prefs ->
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
}
}
override suspend fun markAsUnSeen(roomId: RoomId) {
store.edit { prefs ->
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
}
}
override suspend fun clear() {
dataStoreFile.safeDelete()
}
}

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient,
private val joinRoom: JoinRoom,
private val notificationCleaner: NotificationCleaner,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<AcceptDeclineInviteState> {
@Composable
override fun present(): AcceptDeclineInviteState {
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
)
.onSuccess {
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
seenInvitesStore.markAsUnSeen(roomId)
}
.map { roomId }
}
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
client.ignoreUser(inviteData.senderId).getOrThrow()
}
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
seenInvitesStore.markAsUnSeen(inviteData.roomId)
inviteData.roomId
}.runCatchingUpdatingState(declinedAction)
}

View file

@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - declining invite cancel flow`() = runTest {
val presenter = createAcceptDeclineInvitePresenter()
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
val presenter = createAcceptDeclineInvitePresenter(client = client)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
cancelAndConsumeRemainingEvents()
}
assert(declineInviteFailure).isCalledOnce()
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
}
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
},
ignoreUserResult = ignoreUserSuccess
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
}
)
val presenter = createAcceptDeclineInvitePresenter(client = client)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
}
assertThat(awaitItem().declineAction.isLoading()).isTrue()
}
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
}
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomFailure,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList<String>()),
value(JoinedRoom.Trigger.Invite)
)
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess,
notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
private fun anInviteData(
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit)
},
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter(
client = client,
joinRoom = FakeJoinRoom(joinRoomLambda),
notificationCleaner = notificationCleaner,
seenInvitesStore = seenInvitesStore,
)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.invite.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
api(projects.features.invite.api)
}

View file

@ -0,0 +1,33 @@
/*
* 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.invite.test
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemorySeenInvitesStore(
initialRoomIds: Set<RoomId> = emptySet(),
) : SeenInvitesStore {
private val roomIds = MutableStateFlow(initialRoomIds)
override fun seenRoomIds(): Flow<Set<RoomId>> = roomIds
override suspend fun markAsSeen(roomId: RoomId) {
roomIds.value += roomId
}
override suspend fun markAsUnSeen(roomId: RoomId) {
roomIds.value -= roomId
}
override suspend fun clear() {
roomIds.value = emptySet()
}
}

View file

@ -43,6 +43,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)

View file

@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
@ -69,6 +71,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta,
private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> {
interface Factory {
fun create(
@ -84,7 +87,9 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
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 knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
@ -150,6 +155,10 @@ class JoinRoomPresenter @AssistedInject constructor(
}
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(contentState) {
contentState.markRoomInviteAsSeen()
}
fun handleEvents(event: JoinRoomEvents) {
when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
@ -238,6 +247,12 @@ class JoinRoomPresenter @AssistedInject constructor(
forgetRoom.invoke(roomId)
}
}
private suspend fun ContentState.markRoomInviteAsSeen() {
if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
seenInvitesStore.markAsSeen(roomId)
}
}
}
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {

View file

@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
@ -37,6 +38,7 @@ object JoinRoomModule {
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
appPreferencesStore: AppPreferencesStore,
seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
override fun create(
@ -60,6 +62,7 @@ object JoinRoomModule {
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
)
}
}

View file

@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.ForgetRoom
import io.element.android.features.joinroom.impl.di.KnockRoom
@ -54,6 +56,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -113,14 +116,19 @@ class JoinRoomPresenterTest {
flowOf(Optional.of(roomSummary))
}
}
val seenInvitesStore = InMemorySeenInvitesStore()
val presenter = createJoinRoomPresenter(
matrixClient = matrixClient
matrixClient = matrixClient,
seenInvitesStore = seenInvitesStore,
)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
}
// Check that the roomId is stored in the seen invites store
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
}
}
@ -762,7 +770,8 @@ class JoinRoomPresenterTest {
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore()
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
@ -778,6 +787,7 @@ class JoinRoomPresenterTest {
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
)
}

View file

@ -11,12 +11,14 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
) : LocationActions {
override fun share(location: Location, label: String?) {
runCatching {
val uri = Uri.parse(buildUrl(location, label))
val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
}
}
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
@VisibleForTesting
internal fun buildUrl(
location: Location,
label: String?,
urlEncoder: (String) -> String = Uri::encode
): String {
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
return if (label == null) {
base
} else {
"%s (%s)".format(base, urlEncoder(label))
}
// This is needed so the coordinates are formatted with a dot as decimal separator
val locale = Locale.ENGLISH
return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
}

View file

@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import org.junit.Test
import java.net.URLEncoder
import java.util.Locale
internal class AndroidLocationActionsTest {
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
)
val actual = buildUrl(location, null, ::urlEncoder)
val expected = "geo:0,0?q=1.234568,123.456789"
val expected = "geo:0,0?q=1.234568,123.456789 ()"
assertThat(actual).isEqualTo(expected)
}
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
assertThat(actual).isEqualTo(expected)
}
@Test
fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
// Set a locale with comma as decimal separator
Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
assertThat(actual).isEqualTo(expected)
}
}

View file

@ -14,6 +14,10 @@ plugins {
android {
namespace = "io.element.android.features.lockscreen.impl"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
setupAnvil()
@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -87,7 +87,9 @@ class DefaultBiometricAuthenticatorManager @Inject constructor(
@Composable
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 isAvailable by remember(lifecycleState) {
derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator }

View file

@ -38,7 +38,9 @@ class LockScreenSettingsPresenter @Inject constructor(
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 {
mutableStateOf(false)
}

View file

@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.digit
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -60,7 +68,22 @@ fun PinKeypad(
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
Column(
modifier = modifier,
modifier = modifier.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp) {
val digitChar = event.digit
if (digitChar != null) {
onClick(PinKeypadModel.Number(digitChar))
true
} else if (event.key == Key.Backspace) {
onClick(PinKeypadModel.Back)
true
} else {
false
}
} else {
false
}
},
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
) {
@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Backspace,
contentDescription = null,
contentDescription = stringResource(CommonStrings.a11y_delete),
)
}
}

View file

@ -0,0 +1,131 @@
/*
* 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.lockscreen.impl.unlock.keypad
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isRoot
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.unit.dp
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PinKeypadTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on a number emits the expected event`() {
val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder)
rule.onNode(hasText("1")).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
}
@Test
fun `clicking on the delete previous character button emits the expected event`() {
val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder)
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Back)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `typing using the hardware keyboard emits the expected events`() {
val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder)
rule.onNodeWithText("1").requestFocus()
rule.onAllNodes(isRoot())[0].performKeyInput {
val keys = listOf(
Key.A,
Key.NumPad1,
Key.NumPad2,
Key.NumPad3,
Key.NumPad4,
Key.NumPad5,
Key.NumPad6,
Key.NumPad7,
Key.NumPad8,
Key.NumPad9,
Key.NumPad0,
Key(KeyEvent.KEYCODE_1),
Key(KeyEvent.KEYCODE_2),
Key(KeyEvent.KEYCODE_3),
Key(KeyEvent.KEYCODE_4),
Key(KeyEvent.KEYCODE_5),
Key(KeyEvent.KEYCODE_6),
Key(KeyEvent.KEYCODE_7),
Key(KeyEvent.KEYCODE_8),
Key(KeyEvent.KEYCODE_9),
Key(KeyEvent.KEYCODE_0),
Key.Backspace,
)
for (key in keys) {
pressKey(key)
}
}
eventsRecorder.assertList(
listOf(
// Note that the first key is not a number, but a letter so it's ignored as input
// Then we have the numpad keys
PinKeypadModel.Number('1'),
PinKeypadModel.Number('2'),
PinKeypadModel.Number('3'),
PinKeypadModel.Number('4'),
PinKeypadModel.Number('5'),
PinKeypadModel.Number('6'),
PinKeypadModel.Number('7'),
PinKeypadModel.Number('8'),
PinKeypadModel.Number('9'),
PinKeypadModel.Number('0'),
// And the normal keys from the number row in the keyboard
PinKeypadModel.Number('1'),
PinKeypadModel.Number('2'),
PinKeypadModel.Number('3'),
PinKeypadModel.Number('4'),
PinKeypadModel.Number('5'),
PinKeypadModel.Number('6'),
PinKeypadModel.Number('7'),
PinKeypadModel.Number('8'),
PinKeypadModel.Number('9'),
PinKeypadModel.Number('0'),
PinKeypadModel.Back,
)
)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad(
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
PinKeypad(
onClick = onClick,
maxWidth = 1000.dp,
maxHeight = 1000.dp,
)
}
}
}

View file

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

View file

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

View file

@ -30,7 +30,7 @@ class DefaultMessageParser @Inject constructor(
val parser = Json { ignoreUnknownKeys = true }
val response = parser.decodeFromString(MobileRegistrationResponse.serializer(), message)
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 deviceId = response.deviceId ?: error("No device ID in response")
return ExternalSession(

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -44,6 +46,16 @@ class LogoutPresenter @Inject constructor(
}
.collectAsState(initial = BackupUploadState.Unknown)
var waitingForALongTime by remember { mutableStateOf(false) }
LaunchedEffect(backupUploadState) {
if (backupUploadState is BackupUploadState.Waiting) {
delay(2_000)
waitingForALongTime = true
} else {
waitingForALongTime = false
}
}
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
@ -79,6 +91,7 @@ class LogoutPresenter @Inject constructor(
doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(),
recoveryState = recoveryState,
backupUploadState = backupUploadState,
waitingForALongTime = waitingForALongTime,
logoutAction = logoutAction.value,
eventSink = ::handleEvents
)

View file

@ -18,6 +18,7 @@ data class LogoutState(
val doesBackupExistOnServer: Boolean,
val recoveryState: RecoveryState,
val backupUploadState: BackupUploadState,
val waitingForALongTime: Boolean,
val logoutAction: AsyncAction<Unit>,
val eventSink: (LogoutEvents) -> Unit,
)

View file

@ -29,6 +29,15 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED),
// Last session no backup
aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false),
aLogoutState(
isLastDevice = false,
backupUploadState = BackupUploadState.Waiting,
),
aLogoutState(
isLastDevice = false,
backupUploadState = BackupUploadState.Waiting,
waitingForALongTime = true,
),
)
}
@ -38,6 +47,7 @@ fun aLogoutState(
doesBackupExistOnServer: Boolean = true,
recoveryState: RecoveryState = RecoveryState.ENABLED,
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
waitingForALongTime: Boolean = false,
logoutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LogoutEvents) -> Unit = {},
) = LogoutState(
@ -46,6 +56,7 @@ fun aLogoutState(
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,
backupUploadState = backupUploadState,
waitingForALongTime = waitingForALongTime,
logoutAction = logoutAction,
eventSink = eventSink,
)

View file

@ -143,24 +143,41 @@ private fun ColumnScope.Buttons(
@Composable
private fun Content(
state: LogoutState,
modifier: Modifier = Modifier,
) {
if (state.backupUploadState is BackupUploadState.Uploading) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
)
Text(
modifier = Modifier.align(Alignment.End),
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
style = ElementTheme.typography.fontBodySmRegular,
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
when (state.backupUploadState) {
is BackupUploadState.Uploading -> {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
)
Text(
modifier = Modifier.align(Alignment.End),
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
style = ElementTheme.typography.fontBodySmRegular,
)
}
BackupUploadState.Waiting -> {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
)
if (state.waitingForALongTime) {
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = stringResource(CommonStrings.common_please_check_internet_connection),
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
else -> Unit
}
}
}

View file

@ -44,6 +44,7 @@ class LogoutPresenterTest {
assertThat(initialState.doesBackupExistOnServer).isTrue()
assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN)
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.waitingForALongTime).isFalse()
assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@ -66,6 +67,34 @@ class LogoutPresenterTest {
}
}
@Test
fun `present - initial state - waiting a long time`() = runTest {
val encryptionService = FakeEncryptionService()
encryptionService.givenWaitForBackupUploadSteadyStateFlow(
flow {
emit(BackupUploadState.Waiting)
delay(3_000)
}
)
val presenter = createLogoutPresenter(
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.waitingForALongTime).isFalse()
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
val waitingState = awaitItem()
assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
assertThat(initialState.waitingForALongTime).isFalse()
skipItems(1)
val waitingALongTimeState = awaitItem()
assertThat(waitingALongTimeState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
assertThat(waitingALongTimeState.waitingForALongTime).isTrue()
}
}
@Test
fun `present - initial state - backing up`() = runTest {
val encryptionService = FakeEncryptionService()

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.canSendMessage
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.ui.messages.reply.map
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
}
}
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()

View file

@ -84,7 +84,9 @@ class DefaultActionListPresenter @AssistedInject constructor(
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 pinnedEventIds by remember {
room.roomInfoFlow.map { it.pinnedEventIds }

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
@ -78,8 +79,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
val allowCaption by remember {
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 preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
@ -123,6 +128,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = !useSendQueue,
replyParameters = null,
)
}
}
@ -233,6 +239,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption: String?,
sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
replyParameters: ReplyParameters?,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
@ -247,7 +254,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = null,
progressCallback = progressCallback
progressCallback = progressCallback,
replyParameters = replyParameters,
).getOrThrow()
}.fold(
onSuccess = {

View file

@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -177,7 +178,9 @@ class MessageComposerPresenter @AssistedInject constructor(
}
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) {
if (cameraPermissionState.permissionGranted) {
@ -397,16 +400,16 @@ class MessageComposerPresenter @AssistedInject constructor(
.stateIn(this, SharingStarted.Lazily, emptyList())
combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions ->
val result = suggestionsProcessor.process(
suggestion = suggestion,
roomMembersState = roomMembersState,
roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
)
suggestions.clear()
suggestions.addAll(result)
}
val result = suggestionsProcessor.process(
suggestion = suggestion,
roomMembersState = roomMembersState,
roomAliasSuggestions = roomAliasSuggestions,
currentUserId = currentUserId,
canSendRoomMention = ::canSendRoomMention,
)
suggestions.clear()
suggestions.addAll(result)
}
.collect()
}
}
@ -450,7 +453,19 @@ class MessageComposerPresenter @AssistedInject constructor(
}
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
with(capturedMode) {
replyMessage(
body = message.markdown,
htmlBody = message.html,
intentionalMentions = message.intentionalMentions,
replyParameters = ReplyParameters(
inReplyToEventId = eventId,
enforceThreadReply = inThread,
// This should be false until we add a way to make a reply in a thread an explicit reply to the provided eventId
replyWithinThread = false,
),
)
}
}
}
}

View file

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

View file

@ -7,7 +7,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString
import android.text.SpannedString
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor
@ -71,7 +71,7 @@ fun TimelineItemTextView(
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val mentionSpanUpdater = LocalMentionSpanUpdater.current
val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody)
return SpannableString(bodyWithResolvedMentions)
return SpannedString.valueOf(bodyWithResolvedMentions)
}
@PreviewsDayNight

View file

@ -37,7 +37,9 @@ class TypingNotificationPresenter @Inject constructor(
) : Presenter<TypingNotificationState> {
@Composable
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) {
if (renderTypingNotifications) {
observeRoomTypingMembers()

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@ -105,7 +106,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@ -142,7 +144,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media after pre-processing success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@ -177,7 +180,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media before pre-processing success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@ -287,7 +291,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send image with caption success scenario`() = runTest {
val sendImageResult =
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@ -320,6 +324,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
any(),
)
onDoneListener.assertions().isCalledOnce()
}
@ -328,7 +333,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send video with caption success scenario`() = runTest {
val sendVideoResult =
lambdaRecorder<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@ -361,6 +366,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
any(),
)
onDoneListener.assertions().isCalledOnce()
}
@ -369,7 +375,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send audio with caption success scenario`() = runTest {
val sendAudioResult =
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@ -399,6 +405,7 @@ class AttachmentsPreviewPresenterTest {
value(A_CAPTION),
any(),
any(),
any(),
)
onDoneListener.assertions().isCalledOnce()
}
@ -407,7 +414,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media failure scenario without media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(
@ -435,7 +443,8 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media failure scenario with media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val onDoneListenerResult = lambdaRecorder<Unit> {}

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -611,7 +612,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@ -1110,7 +1111,7 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
Result.success(Unit)
}
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
@ -60,7 +61,7 @@ class VoiceMessageComposerPresenterTest {
)
private val analyticsService = FakeAnalyticsService()
private val sendVoiceMessageResult =
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _ ->
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
private val matrixRoom = FakeMatrixRoom(

View file

@ -33,7 +33,9 @@ class MigrationPresenter @Inject constructor(
@Composable
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) }
// Uncomment this block to run the migration everytime

View file

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

View file

@ -74,6 +74,7 @@ dependencies {
implementation(projects.features.licenses.api)
implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api)
implementation(projects.features.invite.api)
implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose)
@ -103,6 +104,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.ftue.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.test)

View file

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

View file

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

View file

@ -71,9 +71,10 @@ class DeveloperSettingsPresenter @Inject constructor(
val clearCacheAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val customElementCallBaseUrl by appPreferencesStore
.getCustomElementCallBaseUrlFlow()
.collectAsState(initial = null)
val customElementCallBaseUrl by remember {
appPreferencesStore
.getCustomElementCallBaseUrlFlow()
}.collectAsState(initial = null)
val tracingLogLevelFlow = remember {
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 localCoroutineScope = rememberCoroutineScope()
val appNotificationsEnabled = userPushStore
.getNotificationEnabledForDevice()
.collectAsState(initial = false)
val appNotificationsEnabled by remember {
userPushStore.getNotificationEnabledForDevice()
}.collectAsState(initial = false)
val matrixSettings: MutableState<NotificationSettingsState.MatrixSettings> = remember {
mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized)
@ -158,7 +158,7 @@ class NotificationSettingsPresenter @Inject constructor(
matrixSettings = matrixSettings.value,
appSettings = NotificationSettingsState.AppSettings(
systemNotificationsEnabled = systemNotificationsEnabled.value,
appNotificationsEnabled = appNotificationsEnabled.value
appNotificationsEnabled = appNotificationsEnabled,
),
changeNotificationSettingAction = changeNotificationSettingAction.value,
currentPushDistributor = currentDistributor,

View file

@ -11,6 +11,7 @@ import android.content.Context
import coil3.SingletonImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService,
private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
context.cacheDir.deleteRecursively()
// Clear some settings
ftueService.reset()
seenInvitesStore.clear()
// Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted

View file

@ -8,8 +8,11 @@
<string name="screen_advanced_settings_element_call_base_url">"Vlastná Element Call základná URL adresa"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Nastaviť vlastnú základnú URL adresu pre Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Skrytie profilové obrázky v žiadostiach o pozvánku do miestnosti"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Skryť ukážky médií na časovej osi"</string>
<string name="screen_advanced_settings_media_compression_description">"Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimalizovať kvalitu médií"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderovanie a bezpečnosť"</string>
<string name="screen_advanced_settings_push_provider_android">"Poskytovateľ oznámení Push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Vypnite rozšírený textový editor na ručné písanie Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potvrdenia o prečítaní"</string>

View file

@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.test.FakeFtueService
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.Test
@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
val sut = DefaultClearCacheUseCase(
context = InstrumentationRegistry.getInstrumentation().context,
matrixClient = matrixClient,
@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService,
pushService = pushService,
seenInvitesStore = seenInvitesStore,
)
defaultCacheService.clearedCacheEventFlow.test {
sut.invoke()
@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
}
}
}

View file

@ -64,9 +64,9 @@ class BugReportPresenter @Inject constructor(
screenshotHolder.getFileUri()
)
}
val crashInfo: String by crashDataStore
.crashInfo()
.collectAsState(initial = "")
val crashInfo: String by remember {
crashDataStore.crashInfo()
}.collectAsState(initial = "")
val sendingProgress = remember {
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.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -39,13 +40,13 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
mutableStateOf(rageshake.isAvailable())
}
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
val isEnabled = rageshakeDataStore
.isEnabled()
.collectAsState(initial = false)
val isEnabled by remember {
rageshakeDataStore.isEnabled()
}.collectAsState(initial = false)
val sensitivity = rageshakeDataStore
.sensitivity()
.collectAsState(initial = 0f)
val sensitivity by remember {
rageshakeDataStore.sensitivity()
}.collectAsState(initial = 0f)
fun handleEvents(event: RageshakePreferencesEvents) {
when (event) {
@ -56,9 +57,9 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
return RageshakePreferencesState(
isFeatureEnabled = isFeatureAvailable,
isEnabled = isEnabled.value,
isEnabled = isEnabled,
isSupported = isSupported.value,
sensitivity = sensitivity.value,
sensitivity = sensitivity,
eventSink = ::handleEvents
)
}

View file

@ -11,5 +11,6 @@ sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent
data class CopyToClipboard(val text: String) : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
}

View file

@ -24,8 +24,12 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.securityandprivacy.permissions.securityAndPrivacyPermissionsAsState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
@ -45,6 +49,7 @@ import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
@ -65,6 +70,7 @@ class RoomDetailsPresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val clipboardHelper: ClipboardHelper,
) : Presenter<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
@ -122,7 +128,9 @@ class RoomDetailsPresenter @Inject constructor(
}
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) {
room.knockRequestsFlow.collect { value = it.size }
}
@ -132,6 +140,9 @@ class RoomDetailsPresenter @Inject constructor(
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
RoomDetailsEvent.LeaveRoom ->
@ -147,6 +158,10 @@ class RoomDetailsPresenter @Inject constructor(
}
}
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
is RoomDetailsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
}
}
@ -188,6 +203,7 @@ class RoomDetailsPresenter @Inject constructor(
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
@ -42,6 +43,7 @@ data class RoomDetailsState(
val canShowPinnedMessages: Boolean,
val canShowMediaGallery: Boolean,
val pinnedMessagesCount: Int?,
val snackbarMessage: SnackbarMessage?,
val canShowKnockRequests: Boolean,
val knockRequestsCount: Int?,
val canShowSecurityAndPrivacy: Boolean,

View file

@ -17,6 +17,7 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ -111,6 +112,7 @@ fun aRoomDetailsState(
canShowPinnedMessages: Boolean = true,
canShowMediaGallery: Boolean = true,
pinnedMessagesCount: Int? = null,
snackbarMessage: SnackbarMessage? = null,
canShowKnockRequests: Boolean = false,
knockRequestsCount: Int? = null,
canShowSecurityAndPrivacy: Boolean = true,
@ -139,11 +141,12 @@ fun aRoomDetailsState(
canShowPinnedMessages = canShowPinnedMessages,
canShowMediaGallery = canShowMediaGallery,
pinnedMessagesCount = pinnedMessagesCount,
snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
eventSink = eventSink
eventSink = eventSink,
)
fun aRoomNotificationSettings(

View file

@ -55,6 +55,7 @@ import io.element.android.libraries.designsystem.components.button.MainActionBut
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@ -69,6 +70,8 @@ import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ -106,6 +109,7 @@ fun RoomDetailsView(
onProfileClick: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier,
topBar = {
@ -115,6 +119,7 @@ fun RoomDetailsView(
onActionClick = onActionClick
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
@ -135,6 +140,9 @@ fun RoomDetailsView(
openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl)
},
onSubtitleClick = { subtitle ->
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
}
)
}
is RoomDetailsType.Dm -> {
@ -145,6 +153,9 @@ fun RoomDetailsView(
openAvatarPreview = { name, avatarUrl ->
openAvatarPreview(name, avatarUrl)
},
onSubtitleClick = { subtitle ->
state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle))
}
)
}
}
@ -368,6 +379,7 @@ private fun RoomHeaderSection(
roomAlias: RoomAlias?,
heroes: ImmutableList<MatrixUser>,
openAvatarPreview: (url: String) -> Unit,
onSubtitleClick: (String) -> Unit,
) {
Column(
modifier = Modifier
@ -384,7 +396,11 @@ private fun RoomHeaderSection(
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar)
)
TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
TitleAndSubtitle(
title = roomName,
subtitle = roomAlias?.value,
onSubtitleClick = onSubtitleClick,
)
}
}
@ -394,6 +410,7 @@ private fun DmHeaderSection(
otherMember: RoomMember,
roomName: String,
openAvatarPreview: (name: String, url: String) -> Unit,
onSubtitleClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@ -411,6 +428,7 @@ private fun DmHeaderSection(
TitleAndSubtitle(
title = roomName,
subtitle = otherMember.userId.value,
onSubtitleClick = onSubtitleClick,
)
}
}
@ -419,6 +437,7 @@ private fun DmHeaderSection(
private fun TitleAndSubtitle(
title: String,
subtitle: String?,
onSubtitleClick: (String) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
@ -430,6 +449,7 @@ private fun TitleAndSubtitle(
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
modifier = Modifier.niceClickable { onSubtitleClick(subtitle) },
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
@ -612,13 +632,13 @@ private fun PinnedMessagesItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())),
trailingContent =
if (pinnedMessagesCount == null) {
ListItemContent.Custom {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
}
} else {
ListItemContent.Text(pinnedMessagesCount.toString())
},
if (pinnedMessagesCount == null) {
ListItemContent.Custom {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp))
}
} else {
ListItemContent.Text(pinnedMessagesCount.toString())
},
onClick = {
analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton)
onPinnedMessagesClick()

View file

@ -12,6 +12,7 @@ import dagger.Module
import dagger.Provides
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -25,6 +26,7 @@ object RoomMemberModule {
room: MatrixRoom,
userProfilePresenterFactory: UserProfilePresenterFactory,
encryptionService: EncryptionService,
clipboardHelper: ClipboardHelper,
): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
@ -33,6 +35,7 @@ object RoomMemberModule {
room = room,
userProfilePresenterFactory = userProfilePresenterFactory,
encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
)
}
}

View file

@ -19,7 +19,11 @@ import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@ -27,6 +31,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
@ -42,6 +47,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
@Assisted private val roomMemberId: UserId,
private val room: MatrixRoom,
private val encryptionService: EncryptionService,
private val clipboardHelper: ClipboardHelper,
userProfilePresenterFactory: UserProfilePresenterFactory,
) : Presenter<UserProfileState> {
interface Factory {
@ -55,6 +61,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
val snackbarDispatcher = LocalSnackbarDispatcher.current
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val roomMember by room.getRoomMemberAsState(roomMemberId)
LaunchedEffect(Unit) {
// Update room member info when opening this screen
@ -111,7 +119,11 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
UserProfileEvents.WithdrawVerification -> coroutineScope.launch {
encryptionService.withdrawVerification(roomMemberId)
}
else -> Unit
is UserProfileEvents.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
else -> userProfileState.eventSink(event)
}
}
@ -119,13 +131,8 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
userName = roomUserName ?: userProfileState.userName,
avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl,
verificationState = verificationState,
eventSink = { event ->
if (event is UserProfileEvents.WithdrawVerification) {
eventSink(UserProfileEvents.WithdrawVerification)
} else {
userProfileState.eventSink(event)
}
}
snackbarMessage = snackbarMessage,
eventSink = ::eventSink
)
}
}

View file

@ -75,6 +75,9 @@
<item quantity="one">"%1$d osaleja"</item>
<item quantity="other">"%1$d osalejat"</item>
</plurals>
<string name="screen_room_member_list_kick_member_confirmation_action">"Eemalda"</string>
<string name="screen_room_member_list_kick_member_confirmation_description">"Uue kutse saamisel on tal võimalik selle jututoaga uuesti liituda."</string>
<string name="screen_room_member_list_kick_member_confirmation_title">"Kas sa oled kindel, et soovid selle osaleja eemaldada?"</string>
<string name="screen_room_member_list_manage_member_ban">"Eemalda ja sea suhtluskeeld"</string>
<string name="screen_room_member_list_manage_member_remove">"Eemalda kasutaja jututoast"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eemalda ja sea suhtluskeeld"</string>

View file

@ -75,6 +75,9 @@
<item quantity="one">"%1$d személy"</item>
<item quantity="other">"%1$d személy"</item>
</plurals>
<string name="screen_room_member_list_kick_member_confirmation_action">"Eltávolítás"</string>
<string name="screen_room_member_list_kick_member_confirmation_description">"Ehhez a szobához is csatlakozhat, ha meghívják."</string>
<string name="screen_room_member_list_kick_member_confirmation_title">"Biztos, hogy eltávolítja ezt a tagot?"</string>
<string name="screen_room_member_list_manage_member_ban">"Eltávolítás és a tag kitiltása"</string>
<string name="screen_room_member_list_manage_member_remove">"Eltávolítás a szobából"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Eltávolítás és a tag kitiltása"</string>

View file

@ -76,6 +76,9 @@
<item quantity="few">"%1$d osoby"</item>
<item quantity="other">"%1$d osôb"</item>
</plurals>
<string name="screen_room_member_list_kick_member_confirmation_action">"Odstrániť"</string>
<string name="screen_room_member_list_kick_member_confirmation_description">"V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti."</string>
<string name="screen_room_member_list_kick_member_confirmation_title">"Ste si istý, že chcete odstrániť tohto člena?"</string>
<string name="screen_room_member_list_manage_member_ban">"Odstrániť a zakázať člena"</string>
<string name="screen_room_member_list_manage_member_remove">"Odstrániť z miestnosti"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Odstrániť a zakázať člena"</string>

Some files were not shown because too many files have changed in this diff Show more