Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-10-05 17:54:00 +02:00
335 changed files with 2657 additions and 1194 deletions
@@ -51,9 +51,10 @@ if [[ -z ${REPO} ]]; then
exit 1 exit 1
fi fi
echo "Deleting previous screenshots" # Note: the previous screenshots are deliberately kept, so that Paparazzi can leave the ones that only
./gradlew removeOldSnapshots --stacktrace --warn $GRADLE_ARGS # differ by less than `maxPercentDifference` untouched instead of rewriting their Git LFS blob. The
# screenshots of previews that no longer exist are pruned afterwards by `pruneObsoleteSnapshots`, which
# `recordPaparazziDebug` is finalized by.
echo "Record screenshots" echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS
+1 -1
View File
@@ -2,6 +2,6 @@
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" /> <option name="version" value="2.4.20" />
</component> </component>
</project> </project>
@@ -9,6 +9,7 @@
package io.element.android.x package io.element.android.x
import android.app.Application import android.app.Application
import android.os.StrictMode
import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
@@ -38,6 +39,9 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
enableStrictMode()
AppInitializer.getInstance(this).apply { AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java) initializeComponent(CrashInitializer::class.java)
initializeComponent(PlatformInitializer::class.java) initializeComponent(PlatformInitializer::class.java)
@@ -51,4 +55,16 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config
// Remove once https://issuetracker.google.com/issues/477038695 is fixed. // Remove once https://issuetracker.google.com/issues/477038695 is fixed.
isAnchoredDraggableComponentsStrictOffsetCheckEnabled = false isAnchoredDraggableComponentsStrictOffsetCheckEnabled = false
} }
private fun enableStrictMode() {
// Workaround to avoid NetworkOnMainThreadException when using the network on the main thread.
// This is a temporary solution to rustls doing the certificate verification using blocking network requests.
// It should be removed once the underlying issue is fixed.
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectNetwork()
.penaltyLog()
.build()
)
}
} }
@@ -8,8 +8,10 @@
package io.element.android.x.initializer package io.element.android.x.initializer
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.system.Os import android.system.Os
import android.util.Log
import androidx.startup.Initializer import androidx.startup.Initializer
import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
@@ -25,6 +27,15 @@ private const val ELEMENT_X_TARGET = "elementx"
class PlatformInitializer : Initializer<Unit> { class PlatformInitializer : Initializer<Unit> {
override fun create(context: Context) { override fun create(context: Context) {
// Init the SDK with the Android Context, so it can be used with rustls and other Android specific features
runCatchingExceptions {
org.matrix.rustsdk.Android.init(context)
}.onFailure {
// We can't log this to Timber since the logs aren't initialized yet, so we log it to Logcat instead
@SuppressLint("LogNotTimber")
Log.e("PlatformInitializer", "Failed to init the SDK with an Android Context. The app probably won't work properly.", it)
}
val appBindings = context.bindings<AppBindings>() val appBindings = context.bindings<AppBindings>()
val tracingService = appBindings.tracingService() val tracingService = appBindings.tracingService()
val platformService = appBindings.platformService() val platformService = appBindings.platformService()
@@ -47,13 +58,6 @@ class PlatformInitializer : Initializer<Unit> {
platformService.init(tracingConfiguration) platformService.init(tracingConfiguration)
// Also set env variable for rust back trace // Also set env variable for rust back trace
Os.setenv("RUST_BACKTRACE", "1", true) Os.setenv("RUST_BACKTRACE", "1", true)
// Init the SDK with the Android Context, so it can be used with rustls and other Android specific features
runCatchingExceptions {
org.matrix.rustsdk.Android.init(context)
}.onFailure {
Timber.e(it, "Failed to init the SDK with an Android Context. The app probably won't work properly.")
}
} }
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf() override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
@@ -93,7 +93,6 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
@@ -111,14 +110,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.time.Duration
import java.time.Instant
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
// The maximum number of room nodes that should be kept in the backstack at the same time. // The maximum number of room nodes that should be kept in the backstack at the same time.
@@ -185,36 +180,20 @@ class LoggedInFlowNode(
roomMembershipObserver = matrixClient.roomMembershipObserver, roomMembershipObserver = matrixClient.roomMembershipObserver,
) )
private val verificationListener = object : SessionVerificationServiceListener { /**
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { * Display the incoming verification request, the app is expected to be in foreground.
// Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash. */
// This might be because this method is called back from Rust in a background thread. fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
lifecycleScope.launch { lifecycleScope.launch {
val receivedAt = Instant.now() // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it
// Otherwise, the RoomList UI may be incorrectly displayed on top
// Wait until the app is in foreground to display the incoming verification request withTimeout(5.seconds) {
appNavigationStateService.appNavigationState.first { it.isInForeground } backstack.elements.first { elements ->
elements.any { it.key.navTarget == NavTarget.Home }
// TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet
val now = Instant.now()
val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration()
// Discard the incoming verification request if it has timed out
if (elapsedTimeSinceReceived > 2.minutes) {
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
return@launch
} }
// Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it
// Otherwise, the RoomList UI may be incorrectly displayed on top
withTimeout(5.seconds) {
backstack.elements.first { elements ->
elements.any { it.key.navTarget == NavTarget.Home }
}
}
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
} }
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
} }
} }
@@ -229,7 +208,6 @@ class LoggedInFlowNode(
analyticsRoomListStateWatcher.start() analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope) loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration() mediaPreviewConfigMigration()
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size // Wait for the network to be connected before pre-fetching the max file upload size
@@ -258,7 +236,6 @@ class LoggedInFlowNode(
onDestroy = { onDestroy = {
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop() analyticsRoomListStateWatcher.stop()
} }
) )
@@ -40,6 +40,9 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView import io.element.android.appnav.root.RootView
import io.element.android.appnav.session.MatrixSessionCache import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestData
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
@@ -52,6 +55,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -63,6 +67,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
@@ -75,11 +80,16 @@ import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@AssistedInject @AssistedInject
@@ -100,6 +110,7 @@ class RootFlowNode(
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope, @AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@@ -127,6 +138,8 @@ class RootFlowNode(
observeNavState(false) observeNavState(false)
} }
} }
observeIncomingVerificationRequests()
observeRemovedSessions()
super.onBuilt() super.onBuilt()
} }
@@ -203,6 +216,65 @@ class RootFlowNode(
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
pendingLoginParams = null pendingLoginParams = null
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
restoreAllSessions()
}
/**
* Restore all the sessions with a valid token, so that they sync and can receive incoming verification requests.
*/
private fun restoreAllSessions() = lifecycleScope.launch {
sessionStore.getAllSessions()
.filter { it.isTokenValid }
.forEach { sessionData ->
matrixSessionCache.getOrRestore(SessionId(sessionData.userId)).onFailure {
Timber.e(it, "Failed to restore session ${sessionData.userId}")
}
}
}
/**
* Remove from the cache the sessions which have been removed from the session store, for instance after a logout.
*/
private fun observeRemovedSessions() {
sessionStore.sessionsFlow()
.map { sessions -> sessions.map { SessionId(it.userId) }.toSet() }
.withPreviousValue()
.onEach { (previous, current) ->
(previous.orEmpty() - current).forEach { sessionId ->
Timber.d("Session $sessionId removed, remove it from the cache")
matrixSessionCache.remove(sessionId)
}
}
.launchIn(lifecycleScope)
}
private fun observeIncomingVerificationRequests() {
incomingVerificationRequestObserver.incomingVerificationRequests()
.onEach(::onIncomingVerificationRequest)
.launchIn(lifecycleScope)
}
private fun onIncomingVerificationRequest(data: IncomingVerificationRequestData) = lifecycleScope.launch {
// Wait for a logged in flow to be displayed
val currentSessionId = withTimeoutOrNull(5.seconds) {
backstack.elements.map { elements ->
elements.firstNotNullOfOrNull { (it.key.navTarget as? NavTarget.LoggedInFlow)?.sessionId }
}.filterNotNull().first()
}
if (currentSessionId == null) {
Timber.w("Incoming verification request ${data.verificationRequest.details.flowId} discarded, no logged in flow.")
return@launch
}
if (currentSessionId == data.sessionId) {
waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == currentSessionId
}
.attachSession()
.onIncomingVerificationRequest(data.verificationRequest)
} else {
// Display the request on top of the current session, without switching session
backstack.push(NavTarget.OtherAccountIncomingVerificationRequest(data.sessionId, data.verificationRequest))
}
} }
private fun switchToNotLoggedInFlow(params: LoginParams?) { private fun switchToNotLoggedInFlow(params: LoginParams?) {
@@ -294,6 +366,11 @@ class RootFlowNode(
) : NavTarget ) : NavTarget
@Parcelize data object BugReport : NavTarget @Parcelize data object BugReport : NavTarget
@Parcelize data class OtherAccountIncomingVerificationRequest(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -348,6 +425,23 @@ class RootFlowNode(
) )
} }
NavTarget.SplashScreen -> emptyNode(buildContext) NavTarget.SplashScreen -> emptyNode(buildContext)
is NavTarget.OtherAccountIncomingVerificationRequest -> {
if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) {
Timber.w("Couldn't find session ${navTarget.sessionId} for the incoming verification request")
lifecycleScope.launch { backstack.pop() }
return emptyNode(buildContext)
}
val inputs = OtherSessionIncomingVerificationNode.Inputs(
sessionId = navTarget.sessionId,
verificationRequest = navTarget.verificationRequest,
)
val callback = object : OtherSessionIncomingVerificationNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {
override fun onDone() { override fun onDone() {
@@ -21,6 +21,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsUserData import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
@@ -41,6 +44,12 @@ class MatrixSessionCache(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : MatrixClientProvider { ) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>() private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
private val _matrixClients = MutableStateFlow<List<MatrixClient>>(emptyList())
/**
* The [MatrixClient]s currently in the cache.
*/
val matrixClients: StateFlow<List<MatrixClient>> = _matrixClients.asStateFlow()
private val restoreMutex = Mutex() private val restoreMutex = Mutex()
init { init {
@@ -51,10 +60,12 @@ class MatrixSessionCache(
fun removeAll() { fun removeAll() {
sessionIdsToMatrixSession.clear() sessionIdsToMatrixSession.clear()
updateMatrixClients()
} }
fun remove(sessionId: SessionId) { fun remove(sessionId: SessionId) {
sessionIdsToMatrixSession.remove(sessionId) sessionIdsToMatrixSession.remove(sessionId)
updateMatrixClients()
} }
override fun getOrNull(sessionId: SessionId): MatrixClient? { override fun getOrNull(sessionId: SessionId): MatrixClient? {
@@ -122,8 +133,13 @@ class MatrixSessionCache(
matrixClient = matrixClient, matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator, syncOrchestrator = syncOrchestrator,
) )
updateMatrixClients()
syncOrchestrator.start() syncOrchestrator.start()
} }
private fun updateMatrixClients() {
_matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient }
}
} }
private data class InMemoryMatrixSession( private data class InMemoryMatrixSession(
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Element Creations 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.appnav.verification
import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
data class IncomingVerificationRequestData(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
)
/**
* Listens to the incoming verification requests of all the sessions in [MatrixSessionCache], not only the current one.
*/
@Inject
class IncomingVerificationRequestObserver(
private val matrixSessionCache: MatrixSessionCache,
private val appForegroundStateService: AppForegroundStateService,
) {
/**
* Emits the incoming verification requests once the app is in foreground.
* Requests received while the app is in background for too long are discarded.
*/
fun incomingVerificationRequests(): Flow<IncomingVerificationRequestData> = channelFlow {
launch {
matrixSessionCache.matrixClients.withPreviousValue().collect { (previousMatrixClients, matrixClients) ->
val previousMatrixClientSet = previousMatrixClients.orEmpty().toSet()
// Remove the listener of the clients which are not in the cache anymore
(previousMatrixClientSet - matrixClients.toSet()).forEach { matrixClient ->
matrixClient.sessionVerificationService.setListener(null)
}
(matrixClients - previousMatrixClientSet).forEach { matrixClient ->
val sessionId = matrixClient.sessionId
matrixClient.sessionVerificationService.setListener(
object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
launch {
// Wait until the app is in foreground to display the incoming verification request
// TODO there should also be a timeout for > 10 minutes elapsed since the request was created,
// but the SDK doesn't expose that info yet
val isInForeground = withTimeoutOrNull(2.minutes) {
appForegroundStateService.isInForeground.first { it }
}
if (isInForeground == null) {
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
} else {
send(IncomingVerificationRequestData(sessionId, verificationRequest))
}
}
}
}
)
}
}
}
awaitClose {
matrixSessionCache.matrixClients.value.forEach { matrixClient ->
matrixClient.sessionVerificationService.setListener(null)
}
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Element Creations 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.appnav.verification
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import kotlinx.parcelize.Parcelize
/**
* Display an incoming verification request received by a session which is not the current one, without switching the current session.
* It sets up the Session graph of the session receiving the request.
*/
@ContributesNode(AppScope::class)
@AssistedInject
class OtherSessionIncomingVerificationNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
sessionGraphFactory: SessionGraphFactory,
matrixClientProvider: MatrixClientProvider,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
) : ParentNode<OtherSessionIncomingVerificationNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun onDone()
}
@Parcelize
object NavTarget : Parcelable
data class Inputs(
val sessionId: SessionId,
val verificationRequest: VerificationRequest.Incoming,
) : NodeInputs
private val callback: Callback = callback()
private val inputs: Inputs = inputs()
override val graph = sessionGraphFactory.create(
requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" }
)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return incomingVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = IncomingVerificationEntryPoint.Params(inputs.verificationRequest),
callback = object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
callback.onDone()
}
},
)
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = navModel,
modifier = modifier,
)
}
}
@@ -25,6 +25,7 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint import io.element.android.features.login.test.FakeLoginEntryPoint
@@ -50,6 +51,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
import io.element.android.services.apperror.test.FakeAppErrorStateService import io.element.android.services.apperror.test.FakeAppErrorStateService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.node.FakeParentNode
import io.element.android.tests.testutils.presenter.NotUsedPresenter import io.element.android.tests.testutils.presenter.NotUsedPresenter
@@ -183,6 +185,10 @@ class RootFlowNodeTest : RobolectricTest() {
announcementService = FakeAnnouncementService(), announcementService = FakeAnnouncementService(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
incomingVerificationRequestObserver = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
),
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
) )
} }
@@ -70,9 +70,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove // Remove
matrixSessionCache.remove(A_SESSION_ID) matrixSessionCache.remove(A_SESSION_ID)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
} }
@Test @Test
@@ -83,9 +85,11 @@ class MatrixSessionCacheTest {
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
// Remove all // Remove all
matrixSessionCache.removeAll() matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
} }
@Test @Test
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2026 Element Creations 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.appnav.verification
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.session.SyncOrchestrator
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class IncomingVerificationRequestObserverTest {
private val sessionVerificationService1 = FakeSessionVerificationService()
private val sessionVerificationService2 = FakeSessionVerificationService()
@Test
fun `incoming verification requests of all the sessions are emitted with their session id`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
val request = anIncomingVerificationRequest()
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
}
// The listeners are removed when the flow is not collected anymore
assertThat(sessionVerificationService1.listener).isNull()
assertThat(sessionVerificationService2.listener).isNull()
}
@Test
fun `listener is removed when the session is removed from the cache`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
assertThat(sessionVerificationService1.listener).isNotNull()
assertThat(sessionVerificationService2.listener).isNotNull()
matrixSessionCache.remove(A_SESSION_ID)
runCurrent()
assertThat(sessionVerificationService1.listener).isNull()
assertThat(sessionVerificationService2.listener).isNotNull()
}
}
@Test
fun `incoming user verification request of another session is emitted with its session id`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = FakeAppForegroundStateService(),
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
runCurrent()
val request = anIncomingUserVerificationRequest()
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
}
}
@Test
fun `incoming verification request is emitted when the app goes to foreground`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = appForegroundStateService,
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
runCurrent()
val request = anIncomingVerificationRequest()
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
advanceTimeBy(30.seconds)
expectNoEvents()
appForegroundStateService.givenIsInForeground(true)
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
}
}
@Test
fun `incoming verification request is discarded if the app does not go to foreground in time`() = runTest {
val matrixSessionCache = createMatrixSessionCache()
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
val observer = IncomingVerificationRequestObserver(
matrixSessionCache = matrixSessionCache,
appForegroundStateService = appForegroundStateService,
)
observer.incomingVerificationRequests().test {
matrixSessionCache.getOrRestore(A_SESSION_ID)
runCurrent()
sessionVerificationService1.listener!!.onIncomingSessionRequest(anIncomingVerificationRequest())
advanceTimeBy(3.minutes)
appForegroundStateService.givenIsInForeground(true)
runCurrent()
expectNoEvents()
}
}
private fun TestScope.createMatrixSessionCache(): MatrixSessionCache {
val matrixClient1 = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService1,
userIdServerNameLambda = { "server" },
)
val matrixClient2 = FakeMatrixClient(
sessionId = A_SESSION_ID_2,
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService2,
userIdServerNameLambda = { "server" },
)
return MatrixSessionCache(
authenticationService = FakeMatrixAuthenticationService(
matrixClientResult = { sessionId ->
Result.success(if (sessionId == A_SESSION_ID) matrixClient1 else matrixClient2)
},
),
syncOrchestratorFactory = object : SyncOrchestrator.Factory {
override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope) = SyncOrchestrator(
matrixClient = matrixClient,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
)
},
analyticsService = FakeAnalyticsService(),
)
}
private fun anIncomingVerificationRequest() = VerificationRequest.Incoming.OtherSession(
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_SESSION_ID)),
)
private fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_USER_ID_3)),
)
private fun aSessionVerificationRequestDetails(senderProfile: MatrixUser) = SessionVerificationRequestDetails(
senderProfile = senderProfile,
flowId = FlowId("flowId"),
deviceId = DeviceId("deviceId"),
deviceDisplayName = "a device name",
firstSeenTimestamp = 0,
)
}
+89 -10
View File
@@ -101,10 +101,6 @@ allprojects {
// Uncomment to suppress Compose Kotlin compiler compatibility warning // Uncomment to suppress Compose Kotlin compiler compatibility warning
// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) // freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true"))
// Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only")
} }
} }
} }
@@ -149,7 +145,13 @@ sonar {
allprojects { allprojects {
tasks.withType<Test> { tasks.withType<Test> {
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) // Robolectric 4.17 sets up shared memory during application setup, which goes through its
// FileDescriptor interceptor. That interceptor reaches into `java.io.FileDescriptor` and
// `jdk.internal.access.SharedSecrets`, both of which are encapsulated since JDK 17.
jvmArgs(
"--add-opens=java.base/java.io=ALL-UNNAMED",
"--add-exports=java.base/jdk.internal.access=ALL-UNNAMED",
)
val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
if (isScreenshotTest) { if (isScreenshotTest) {
@@ -173,6 +175,9 @@ allprojects {
// Gradle parallelise by running many modules' test tasks concurrently instead. // Gradle parallelise by running many modules' test tasks concurrently instead.
maxParallelForks = 1 maxParallelForks = 1
// Increase heap size for screenshot tests
maxHeapSize = "2g"
// Disable screenshot tests by default // Disable screenshot tests by default
exclude("ui/*.class") exclude("ui/*.class")
exclude("translations/*.class") exclude("translations/*.class")
@@ -211,19 +216,93 @@ tasks.register("generateDocsToc", Exec::class.java) {
commandLine("python3", "tools/docs/generate_toc.py", *inputs.files.map { it.path }.toTypedArray()) commandLine("python3", "tools/docs/generate_toc.py", *inputs.files.map { it.path }.toTypedArray())
} }
// Make sure to delete old screenshots before recording new ones // Delete all the screenshots. Not hooked to the record tasks on purpose, see `pruneObsoleteSnapshots`
// below. Run it by hand (or use Paparazzi's own `cleanRecordPaparazziDebug`) to record from scratch.
subprojects { subprojects {
val snapshotsDir = File("${project.projectDir}/src/test/snapshots") val snapshotsDir = File("${project.projectDir}/src/test/snapshots")
val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") { tasks.register("removeOldSnapshots") {
onlyIf { snapshotsDir.exists() } onlyIf { snapshotsDir.exists() }
doFirst { doFirst {
println("Delete previous screenshots located at $snapshotsDir\n") println("Delete previous screenshots located at $snapshotsDir\n")
snapshotsDir.deleteRecursively() snapshotsDir.deleteRecursively()
} }
} }
tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask) }
tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask)
tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) // Delete the screenshots of previews that no longer exist, *after* recording.
//
// Wiping the whole snapshots directory before recording used to be how stale screenshots were removed,
// but it also removes the reference image Paparazzi needs to honour
// `app.cash.paparazzi.overwriteOnMaxPercentDifference` (see gradle.properties): with no existing file to
// compare against, every screenshot is rewritten unconditionally, so renderer noise far below the
// threshold `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot.
//
// Instead, let the record task keep the existing screenshots, then prune the ones belonging to a
// preview that Paparazzi did not render at all.
//
// Pruning per preview, and not per screenshot, is deliberate: a preview that fails to render is absent
// from the run report in exactly the same way as a preview that was deleted, and LayoutLib does fail to
// render a preview now and then. Matching whole previews means such
// a failure leaves the other screenshots of that preview in place, so it cannot silently delete a
// screenshot that is still in use. The cost is that dropping one value from a PreviewParameterProvider
// leaves its last screenshot behind, since the preview itself still renders. Run `removeOldSnapshots`
// and record again to clean those up.
subprojects {
val projectDir = project.projectDir
val pruneObsoleteSnapshots = tasks.register("pruneObsoleteSnapshots") {
description = "Delete the recorded screenshots of previews that no longer exist"
val imagesDir = File(projectDir, "src/test/snapshots/images")
val reportsDir = File(projectDir, "build/reports/paparazzi")
onlyIf { imagesDir.exists() }
doLast {
// One `runs/<runName>.js` per forked test JVM, each holding `window.runs[...] = [ ...snapshots... ];`.
val runFiles = reportsDir.walkTopDown()
.filter { it.isFile && it.extension == "js" && it.parentFile.name == "runs" }
.toList()
check(runFiles.isNotEmpty()) {
"No Paparazzi run report found under $reportsDir, so there is no way to tell which " +
"screenshots are still in use. Refusing to delete anything."
}
// `testName` is serialised as "<package>.<class>#<method>" and the screenshot file is named
// "<package>_<class>_<method>.png". Only the package and the class are used here, they identify
// the preview. Do not be tempted to rebuild the whole file name from the method: it has to be
// normalised exactly as `Snapshot.toFileName` does, which replaces whitespace with the
// delimiter, so a preview named "List item - Simple" does not read as its own file name.
val testNameRegex = """"testName"\s*:\s*"(.*)\.([^.]*)#""".toRegex()
val renderedPreviews = runFiles.flatMap { runFile ->
testNameRegex.findAll(runFile.readText()).map { match ->
val (packageName, className) = match.destructured
// Keep the trailing delimiter, so that `Foo_` does not also match `FooBar_…`.
"${packageName}_${className}_"
}
}.toSet()
check(renderedPreviews.isNotEmpty()) {
"Could not read any snapshot name out of the Paparazzi run reports in $reportsDir. " +
"The report format has probably changed. Refusing to delete anything."
}
val existing = imagesDir.listFiles { file -> file.extension == "png" }.orEmpty()
val obsolete = existing.filter { file -> renderedPreviews.none { file.name.startsWith(it) } }
// Deleting a screenshot is a tracked file deletion, and a bad `renderedPreviews` set would wipe
// thousands of them. Anything beyond a trickle means something is wrong rather than a few
// deleted previews.
check(obsolete.size <= existing.size / 10) {
"pruneObsoleteSnapshots would delete ${obsolete.size} of ${existing.size} screenshots in " +
"$imagesDir, which looks like a bug rather than deleted previews. Refusing to delete " +
"anything. If the deletions are expected, run `removeOldSnapshots` and record again."
}
obsolete.forEach {
println("Delete obsolete screenshot ${it.name}")
it.delete()
}
println("Pruned ${obsolete.size} obsolete screenshot(s), kept ${existing.size - obsolete.size}.\n")
}
}
// `configureEach` on a live filtered collection, and not `findByName`, because the Paparazzi plugin
// registers its record tasks per variant, long after this block runs.
tasks.matching { it.name.startsWith("recordPaparazzi") }.configureEach {
finalizedBy(pruneObsoleteSnapshots)
}
} }
subprojects { subprojects {
@@ -53,7 +53,7 @@ class ContributesNodeProcessor(
val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!) val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!)
.filterIsInstance<KSClassDeclaration>() .filterIsInstance<KSClassDeclaration>()
val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() } val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate(enableNewFeatures = false) }
if (validSymbols.isEmpty()) return invalidSymbols if (validSymbols.isEmpty()) return invalidSymbols
@@ -9,9 +9,11 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
@@ -43,18 +45,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -296,11 +290,15 @@ private fun NavigationIcon(
onAccountSwitch: (SessionId) -> Unit, onAccountSwitch: (SessionId) -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
// Reserve the status emoji slot for all the accounts, else the width of the navigation icon,
// and so the position of the title, would change while scrolling the pager.
val reserveStatusEmojiSlot = currentUserAndNeighbors.any { it.displayedStatus != null }
if (currentUserAndNeighbors.size == 1) { if (currentUserAndNeighbors.size == 1) {
AccountIcon( AccountIcon(
matrixUser = currentUserAndNeighbors.single(), matrixUser = currentUserAndNeighbors.single(),
isCurrentAccount = true, isCurrentAccount = true,
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,
reserveStatusEmojiSlot = reserveStatusEmojiSlot,
onClick = onClick, onClick = onClick,
) )
} else { } else {
@@ -321,6 +319,7 @@ private fun NavigationIcon(
matrixUser = currentUserAndNeighbors[page], matrixUser = currentUserAndNeighbors[page],
isCurrentAccount = page == 1, isCurrentAccount = page == 1,
showAvatarIndicator = page == 1 && showAvatarIndicator, showAvatarIndicator = page == 1 && showAvatarIndicator,
reserveStatusEmojiSlot = reserveStatusEmojiSlot,
onClick = if (page == 1) { onClick = if (page == 1) {
onClick onClick
} else { } else {
@@ -336,95 +335,74 @@ private fun AccountIcon(
matrixUser: MatrixUser, matrixUser: MatrixUser,
isCurrentAccount: Boolean, isCurrentAccount: Boolean,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
reserveStatusEmojiSlot: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
Box( Row(
modifier = modifier modifier = modifier
.then(testTag) .then(testTag)
.minimumInteractiveComponentSize()
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
indication = ripple(bounded = false), // The ripple is rendered on the avatar only, see below.
indication = null,
), ),
contentAlignment = Alignment.Center, verticalAlignment = Alignment.CenterVertically,
) { ) {
val avatarData by remember(matrixUser) { Box(
derivedStateOf { modifier = Modifier
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) .minimumInteractiveComponentSize()
.indication(
interactionSource = interactionSource,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
} else {
stringResource(CommonStrings.common_settings)
}
} else {
null
},
)
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
} }
} }
val statusEmoji = matrixUser.displayedStatus?.toEmojiText() if (reserveStatusEmojiSlot) {
val avatarModifier = if (statusEmoji != null) { StatusEmoji(
Modifier.eraseStatusEmojiBackground( emoji = matrixUser.displayedStatus?.toEmojiText(),
parentSize = AvatarSize.CurrentUserTopBar.dp, modifier = Modifier.offset(x = statusEmojiStartOffset),
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
} else {
stringResource(CommonStrings.common_settings)
}
} else {
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
) )
} }
} }
} }
private val statusEmojiBadgeSize = 20.dp private val statusEmojiSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground( // The avatar is centered inside a 48dp touch target, so there is already 8dp of free space
parentSize: Dp, // on its trailing side. Compensate it to end up with a 4dp gap after the avatar itself.
layoutDirection: LayoutDirection, private val statusEmojiStartOffset = 4.dp - (48.dp - AvatarSize.CurrentUserTopBar.dp) / 2
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable @Composable
private fun StatusEmojiBadge( private fun StatusEmoji(
emoji: String, emoji: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
@@ -433,16 +411,16 @@ private fun StatusEmojiBadge(
Density(density = density.density, fontScale = 1f) Density(density = density.density, fontScale = 1f)
} }
Box( Box(
modifier = modifier modifier = modifier.size(statusEmojiSize),
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { if (emoji != null) {
Text( CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
text = emoji, Text(
style = ElementTheme.typography.fontBodyMdRegular, text = emoji,
) style = ElementTheme.typography.fontBodyMdRegular,
)
}
} }
} }
} }
@@ -8,7 +8,6 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -23,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -129,6 +127,9 @@ private fun SkeletonView(
} }
} }
/**
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=8-4656
*/
@Composable @Composable
private fun EmptyView( private fun EmptyView(
state: RoomListContentState.Empty, state: RoomListContentState.Empty,
@@ -139,13 +140,19 @@ private fun EmptyView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
val text = buildString {
append(stringResource(R.string.screen_roomlist_empty_title))
append("\n")
append(stringResource(R.string.screen_roomlist_empty_message))
}
EmptyScaffold( EmptyScaffold(
title = R.string.screen_roomlist_empty_title, title = null,
subtitle = R.string.screen_roomlist_empty_message, subtitle = text,
action = { bottomPart = {
Spacer(modifier = Modifier.height(16.dp))
Button( Button(
text = stringResource(CommonStrings.action_start_chat), text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()), leadingIcon = IconSource.Vector(CompoundIcons.Chat()),
onClick = onCreateRoomClick, onClick = onCreateRoomClick,
) )
}, },
@@ -280,6 +287,7 @@ private fun RoomsViewList(
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE && isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId), state.seenRoomInvites.contains(room.roomId),
showUnreadCount = state.showUnreadCount, showUnreadCount = state.showUnreadCount,
showAllActivity = state.showAllActivity,
onClick = onRoomClick, onClick = onRoomClick,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -298,39 +306,44 @@ private fun EmptyViewForFilterStates(
) { ) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
EmptyScaffold( EmptyScaffold(
title = emptyStateResources.title, title = stringResource(emptyStateResources.title),
subtitle = emptyStateResources.subtitle, subtitle = stringResource(emptyStateResources.subtitle),
modifier = modifier, modifier = modifier,
bottomPart = {
// Keep a spacer so that the content is not too low on the screen
Spacer(modifier = Modifier.height(32.dp))
}
) )
} }
@Composable @Composable
private fun EmptyScaffold( private fun EmptyScaffold(
@StringRes title: Int, title: String?,
@StringRes subtitle: Int, subtitle: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
action: @Composable (ColumnScope.() -> Unit)? = null, bottomPart: @Composable ColumnScope.() -> Unit,
) { ) {
Column( Column(
modifier = modifier.padding(horizontal = 60.dp), modifier = modifier.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
title?.let {
Text(
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
}
Text( Text(
text = stringResource(title), text = subtitle,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(subtitle),
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(32.dp)) bottomPart()
action?.invoke(this)
} }
} }
@@ -80,6 +80,7 @@ internal fun RoomSummaryRow(
onClick: (RoomListRoomSummary) -> Unit, onClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showUnreadCount: Boolean = false, showUnreadCount: Boolean = false,
showAllActivity: Boolean = false,
eventSink: (RoomListEvent) -> Unit, eventSink: (RoomListEvent) -> Unit,
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@@ -131,7 +132,7 @@ internal fun RoomSummaryRow(
isHighlighted = room.isHighlighted, isHighlighted = room.isHighlighted,
dmUserStatus = room.dmUserStatus, dmUserStatus = room.dmUserStatus,
) )
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount) MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount, showAllActivity = showAllActivity)
} }
} }
RoomSummaryDisplayType.KNOCKED -> { RoomSummaryDisplayType.KNOCKED -> {
@@ -278,6 +279,7 @@ private fun InviteSubtitle(
private fun MessagePreviewAndIndicatorRow( private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
showUnreadCount: Boolean, showUnreadCount: Boolean,
showAllActivity: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -348,7 +350,7 @@ private fun MessagePreviewAndIndicatorRow(
.height(16.dp) .height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback // Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f), .zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
@@ -358,25 +360,17 @@ private fun MessagePreviewAndIndicatorRow(
isAudio = room.activeCallIntent == CallIntent.AUDIO isAudio = room.activeCallIntent == CallIntent.AUDIO
) )
} }
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
if (isMuted) {
NotificationOffIndicatorAtom() NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) { } else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom() MentionIndicatorAtom()
} }
if (room.hasNewContent) { if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
val count = if (showUnreadCount) {
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
room.numberOfUnreadMessages
} else {
room.numberOfUnreadNotifications
}
} else {
null
}
UnreadIndicatorAtom( UnreadIndicatorAtom(
color = tint, color = tint,
count = count, count = room.unreadIndicatorCount(showUnreadCount = showUnreadCount, showAllActivity = showAllActivity),
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
@@ -384,6 +378,27 @@ private fun MessagePreviewAndIndicatorRow(
} }
} }
/**
* Computes the value to pass to [UnreadIndicatorAtom] as `count`.
* - `null`: nothing is displayed.
* - `0`: dot indicator without a count.
* - `> 0`: the number to display.
*/
internal fun RoomListRoomSummary.unreadIndicatorCount(
showUnreadCount: Boolean,
showAllActivity: Boolean,
): Long? {
val isMuted = userDefinedNotificationMode == RoomNotificationMode.MUTE
return when {
// Show only unread notifications when the room is not muted and there are unread notifications
!isMuted && numberOfUnreadNotifications > 0 -> if (showUnreadCount) numberOfUnreadNotifications else 0
// Otherwise, show a dot for unread messages when showAllActivity is true and there are unread messages
showAllActivity -> if (numberOfUnreadMessages > 0) 0 else null
// Or don't display anything
else -> null
}
}
@Composable @Composable
private fun InviteNameAndIndicatorRow( private fun InviteNameAndIndicatorRow(
name: String?, name: String?,
@@ -408,7 +423,8 @@ private fun InviteNameAndIndicatorRow(
) )
if (!isInviteSeen) { if (!isInviteSeen) {
UnreadIndicatorAtom( UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator color = ElementTheme.colors.unreadIndicator,
count = 0,
) )
} }
} }
@@ -457,5 +473,7 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
isInviteSeen = data.name == "Bob", isInviteSeen = data.name == "Bob",
onClick = {}, onClick = {},
eventSink = {}, eventSink = {},
showUnreadCount = data.numberOfUnreadNotifications > 0,
showAllActivity = true,
) )
} }
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
hasRoomCall = hasCall, hasRoomCall = hasCall,
), ),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 100,
hasRoomCall = hasCall,
),
) )
}.flatten() }.flatten()
}.flatten(), }.flatten(),
@@ -40,6 +40,7 @@ internal fun aRoomsContentState(
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(), summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(), batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
showAllActivity: Boolean = false,
seenRoomInvites: Set<RoomId> = emptySet(), seenRoomInvites: Set<RoomId> = emptySet(),
) = RoomListContentState.Rooms( ) = RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
@@ -48,6 +49,7 @@ internal fun aRoomsContentState(
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState, batteryOptimizationState = batteryOptimizationState,
summaries = summaries, summaries = summaries,
showAllActivity = showAllActivity,
seenRoomInvites = seenRoomInvites.toImmutableSet(), seenRoomInvites = seenRoomInvites.toImmutableSet(),
) )
@@ -118,6 +118,10 @@ class RoomListPresenter(
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) } val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
val showAllActivity by produceState(initialValue = false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it }
}
fun handleEvent(event: RoomListEvent) { fun handleEvent(event: RoomListEvent) {
when (event) { when (event) {
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
@@ -165,13 +169,14 @@ class RoomListPresenter(
val canReportRoom by produceState(false) { value = client.canReportRoom() } val canReportRoom by produceState(false) { value = client.canReportRoom() }
val showUnreadCount by produceState(false) { val showUnreadCount by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount) featureFlagService.isFeatureEnabledFlow(FeatureFlags.UnreadIndicatorCount).collect { value = it }
} }
val contentState = roomListContentState( val contentState = roomListContentState(
securityBannerDismissed, securityBannerDismissed,
showNewNotificationSoundBanner, showNewNotificationSoundBanner,
showUnreadCount, showUnreadCount,
showAllActivity,
) )
return RoomListState( return RoomListState(
@@ -230,6 +235,7 @@ class RoomListPresenter(
securityBannerDismissed: Boolean, securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
showUnreadCount: Boolean, showUnreadCount: Boolean,
showAllActivity: Boolean,
): RoomListContentState { ): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) { val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) } roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -262,6 +268,7 @@ class RoomListPresenter(
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(), summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
showAllActivity = showAllActivity,
seenRoomInvites = seenRoomInvites.toImmutableSet(), seenRoomInvites = seenRoomInvites.toImmutableSet(),
) )
} }
@@ -76,5 +76,6 @@ sealed interface RoomListContentState {
val showUnreadCount: Boolean, val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>, val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>, val seenRoomInvites: ImmutableSet<RoomId>,
val showAllActivity: Boolean,
) : RoomListContentState ) : RoomListContentState
} }
@@ -29,5 +29,11 @@ sealed interface GlobalSearchEvent {
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
/** The user selected a search result from the search history. */ /** The user selected a search result from the search history. */
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent data class SearchHistoryResultSelected(val resultItem: SearchHistoryListItem) : GlobalSearchEvent
/** The user removed a search history entry. */
data class RemoveSearchHistoryResult(val result: SearchHistoryListItem) : GlobalSearchEvent
/** The user cleared the search history. */
data object ClearSearchHistory : GlobalSearchEvent
} }
@@ -57,6 +57,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -87,14 +88,14 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) { val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history -> searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result -> val mappedHistoryItems = history.mapNotNull { result ->
when (result) { when (result) {
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term) is SearchHistoryResult.Query -> SearchHistoryListItem.Query(term = result.term)
is SearchHistoryResult.Room -> { is SearchHistoryResult.Room -> {
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo) SearchHistoryListItem.Room(roomId = result.roomId, roomInfo = roomInfo)
} }
} }
} }
@@ -236,7 +237,7 @@ class GlobalSearchPresenter(
} }
is GlobalSearchEvent.SearchHistoryResultSelected -> { is GlobalSearchEvent.SearchHistoryResultSelected -> {
when (event.resultItem) { when (event.resultItem) {
is SearchHistoryResultItem.Query -> coroutineScope.launch { is SearchHistoryListItem.Query -> coroutineScope.launch {
delay(300.milliseconds) delay(300.milliseconds)
queryState.edit { queryState.edit {
replace(0, length, event.resultItem.term) replace(0, length, event.resultItem.term)
@@ -244,9 +245,21 @@ class GlobalSearchPresenter(
searchResults = AsyncData.Loading() searchResults = AsyncData.Loading()
currentTarget = GlobalSearchTarget.ROOMS currentTarget = GlobalSearchTarget.ROOMS
} }
is SearchHistoryResultItem.Room -> Unit is SearchHistoryListItem.Room -> Unit
} }
} }
is GlobalSearchEvent.RemoveSearchHistoryResult -> coroutineScope.launch {
val currentHistory = searchHistoryStore.history.first()
val id = when (event.result) {
is SearchHistoryListItem.Query -> event.result.term
is SearchHistoryListItem.Room -> event.result.roomId.value
}
val result = currentHistory.find { it.id == id } ?: return@launch
searchHistoryStore.remove(result)
}
GlobalSearchEvent.ClearSearchHistory -> coroutineScope.launch {
searchHistoryStore.clear()
}
} }
} }
@@ -265,7 +278,7 @@ class GlobalSearchPresenter(
result: MessageSearchResult, result: MessageSearchResult,
roomInfo: RoomInfo, roomInfo: RoomInfo,
formattedTimestamp: String, formattedTimestamp: String,
): MessageSearchResultItem.Message? { ): MessageSearchListItem.Message? {
val body = latestEventFormatter.format( val body = latestEventFormatter.format(
latestEvent = LatestEventValue.Remote( latestEvent = LatestEventValue.Remote(
timestamp = result.timestamp, timestamp = result.timestamp,
@@ -276,7 +289,7 @@ class GlobalSearchPresenter(
), ),
isDmRoom = false, isDmRoom = false,
) )
return MessageSearchResultItem.Message( return MessageSearchListItem.Message(
messageSearchResult = result, messageSearchResult = result,
body = body?.toString() ?: "", body = body?.toString() ?: "",
formattedTimestamp = formattedTimestamp, formattedTimestamp = formattedTimestamp,
@@ -288,7 +301,7 @@ class GlobalSearchPresenter(
result: MessageSearchResult, result: MessageSearchResult,
roomInfo: RoomInfo, roomInfo: RoomInfo,
formattedTimestamp: String, formattedTimestamp: String,
): MessageSearchResultItem.Media? { ): MessageSearchListItem.Media? {
val messageType = when (val content = result.content) { val messageType = when (val content = result.content) {
is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
else -> return null else -> return null
@@ -342,7 +355,7 @@ class GlobalSearchPresenter(
blurhash = blurhash blurhash = blurhash
) )
return MessageSearchResultItem.Media( return MessageSearchListItem.Media(
messageSearchResult = result, messageSearchResult = result,
mediaContent = mediaContent, mediaContent = mediaContent,
formattedTimestamp = formattedTimestamp, formattedTimestamp = formattedTimestamp,
@@ -28,20 +28,26 @@ data class GlobalSearchState(
val queryState: TextFieldState, val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget, val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>, val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>, val history: AsyncData<ImmutableList<SearchHistoryListItem>>,
val eventSink: (GlobalSearchEvent) -> Unit val eventSink: (GlobalSearchEvent) -> Unit
) )
/**
* The target of the global search: either rooms or messages.
*/
@Immutable @Immutable
enum class GlobalSearchTarget { enum class GlobalSearchTarget {
ROOMS, ROOMS,
MESSAGES, MESSAGES,
} }
/**
* The results of a global search, either a list of rooms or a list of messages.
*/
@Immutable @Immutable
sealed interface GlobalSearchResults { sealed interface GlobalSearchResults {
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : GlobalSearchResults data class MessageSearchResults(val results: ImmutableList<MessageSearchListItem>) : GlobalSearchResults
fun isEmpty(): Boolean = when (this) { fun isEmpty(): Boolean = when (this) {
is RoomListResults -> results.isEmpty() is RoomListResults -> results.isEmpty()
@@ -49,7 +55,10 @@ sealed interface GlobalSearchResults {
} }
} }
sealed interface MessageSearchResultItem { /**
* A single item in the message search results. It's the UI representation of [MessageSearchResult], which is the mapped model returned by the Matrix SDK.
*/
sealed interface MessageSearchListItem {
val roomId: RoomId val roomId: RoomId
val eventId: EventId val eventId: EventId
val senderId: UserId val senderId: UserId
@@ -57,24 +66,30 @@ sealed interface MessageSearchResultItem {
val roomInfo: RoomInfo val roomInfo: RoomInfo
val formattedTimestamp: String val formattedTimestamp: String
/**
* A message item in the message search results.
*/
data class Message( data class Message(
val messageSearchResult: MessageSearchResult, val messageSearchResult: MessageSearchResult,
val body: String, val body: String,
override val roomInfo: RoomInfo, override val roomInfo: RoomInfo,
override val formattedTimestamp: String, override val formattedTimestamp: String,
) : MessageSearchResultItem { ) : MessageSearchListItem {
override val roomId: RoomId = messageSearchResult.roomId override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId override val eventId: EventId = messageSearchResult.eventId
override val senderId: UserId = messageSearchResult.senderId override val senderId: UserId = messageSearchResult.senderId
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
} }
/**
* A message with media item in the message search results.
*/
data class Media( data class Media(
val messageSearchResult: MessageSearchResult, val messageSearchResult: MessageSearchResult,
val mediaContent: MediaSearchResultContent, val mediaContent: MediaSearchResultContent,
override val roomInfo: RoomInfo, override val roomInfo: RoomInfo,
override val formattedTimestamp: String, override val formattedTimestamp: String,
) : MessageSearchResultItem { ) : MessageSearchListItem {
override val roomId: RoomId = messageSearchResult.roomId override val roomId: RoomId = messageSearchResult.roomId
override val eventId: EventId = messageSearchResult.eventId override val eventId: EventId = messageSearchResult.eventId
override val senderId: UserId = messageSearchResult.senderId override val senderId: UserId = messageSearchResult.senderId
@@ -82,20 +97,28 @@ sealed interface MessageSearchResultItem {
} }
} }
sealed interface SearchHistoryResultItem { /**
* A single entry in the global search history, displayed in the list of recent searches.
*
* It's the UI representation of [io.element.android.features.home.impl.search.history.SearchHistoryResult], which is the persisted model.
*/
sealed interface SearchHistoryListItem {
val id: String val id: String
data class Query(val term: String) : SearchHistoryResultItem { data class Query(val term: String) : SearchHistoryListItem {
override val id: String = "query:$term" override val id: String = "query:$term"
} }
data class Room( data class Room(
val roomId: RoomId, val roomId: RoomId,
val roomInfo: RoomInfo, val roomInfo: RoomInfo,
) : SearchHistoryResultItem { ) : SearchHistoryListItem {
override val id: String = "room:${roomId.value}" override val id: String = "room:${roomId.value}"
} }
} }
/**
* Represents the media content of a message search result.
*/
data class MediaSearchResultContent( data class MediaSearchResultContent(
val filename: String, val filename: String,
val extension: String?, val extension: String?,
@@ -79,13 +79,13 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
currentTarget = GlobalSearchTarget.MESSAGES, currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"), queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf( results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message( MessageSearchListItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")), messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query", body = "A message with Query",
roomInfo = aRoomInfo(), roomInfo = aRoomInfo(),
formattedTimestamp = "12:00", formattedTimestamp = "12:00",
), ),
MessageSearchResultItem.Media( MessageSearchListItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")), messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent( mediaContent = MediaSearchResultContent(
filename = "file.png", filename = "file.png",
@@ -114,9 +114,9 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
results = AsyncData.Uninitialized, results = AsyncData.Uninitialized,
history = AsyncData.Success( history = AsyncData.Success(
persistentListOf( persistentListOf(
SearchHistoryResultItem.Query("Query 1"), SearchHistoryListItem.Query("Query 1"),
SearchHistoryResultItem.Query("Query 2"), SearchHistoryListItem.Query("Query 2"),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = roomId1, roomId = roomId1,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = roomId1, id = roomId1,
@@ -125,7 +125,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
canonicalAlias = aCanonicalAlias, canonicalAlias = aCanonicalAlias,
) )
), ),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = roomId2, roomId = roomId2,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = roomId2, id = roomId2,
@@ -133,7 +133,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
) )
), ),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = dmRoomId, roomId = dmRoomId,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = dmRoomId, id = dmRoomId,
@@ -144,7 +144,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
canonicalAlias = aCanonicalAlias, canonicalAlias = aCanonicalAlias,
) )
), ),
SearchHistoryResultItem.Room( SearchHistoryListItem.Room(
roomId = tombstonedRoomId, roomId = tombstonedRoomId,
roomInfo = aRoomInfo( roomInfo = aRoomInfo(
id = tombstonedRoomId, id = tombstonedRoomId,
@@ -300,7 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(), queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS, currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized, results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized, history: AsyncData<ImmutableList<SearchHistoryListItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {}, eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState( ) = GlobalSearchState(
isEnabled = isEnabled, isEnabled = isEnabled,
@@ -12,12 +12,14 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -31,9 +33,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -67,6 +71,8 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.FilledTextField import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@@ -75,6 +81,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SegmentedButton import io.element.android.libraries.designsystem.theme.components.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
@@ -155,10 +162,14 @@ private fun GlobalSearchContent(
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf() val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when { when {
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) { state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
searchHistory(historyResults) { result -> searchHistory(
historyResults = historyResults,
onRemoveResult = { item -> state.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(item)) },
onClearAllResults = { state.eventSink(GlobalSearchEvent.ClearSearchHistory) },
) { result ->
when (result) { when (result) {
is SearchHistoryResultItem.Query -> Unit is SearchHistoryListItem.Query -> Unit
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null) is SearchHistoryListItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
} }
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result)) state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
@@ -214,7 +225,7 @@ private fun SearchTopAppBarContent(state: GlobalSearchState) {
modifier = Modifier.clip(CircleShape).size(20.dp), modifier = Modifier.clip(CircleShape).size(20.dp),
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }, onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.borderInteractivePrimary, containerColor = ElementTheme.colors.iconSecondary,
contentColor = ElementTheme.colors.iconOnSolidPrimary contentColor = ElementTheme.colors.iconOnSolidPrimary
) )
) { ) {
@@ -279,31 +290,52 @@ private fun LazyListScope.startSearching() {
} }
private fun LazyListScope.searchHistory( private fun LazyListScope.searchHistory(
historyResults: ImmutableList<SearchHistoryResultItem>, historyResults: ImmutableList<SearchHistoryListItem>,
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit, onRemoveResult: (SearchHistoryListItem) -> Unit,
onClearAllResults: () -> Unit,
onSelectSearchHistoryResult: (SearchHistoryListItem) -> Unit,
) { ) {
item { item {
Text( Row(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), modifier = Modifier.padding(top = 16.dp),
text = stringResource(R.string.screen_search_recent_searches_section_title), verticalAlignment = Alignment.CenterVertically,
style = ElementTheme.typography.fontBodyLgMedium, ) {
color = ElementTheme.colors.textPrimary, Text(
) modifier = Modifier.padding(start = 16.dp).weight(1f),
text = stringResource(R.string.screen_search_recent_searches_section_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
TextButton(
text = stringResource(CommonStrings.action_clear_all),
size = ButtonSize.Small,
colors = ButtonDefaults.textButtonColors(contentColor = ElementTheme.colors.textActionAccent),
onClick = onClearAllResults,
)
}
} }
items( items(
items = historyResults, items = historyResults,
key = { it.id }, key = { it.id },
) { result -> ) { result ->
val twoLineHeight =
// Vertical padding
12.dp * 2 +
// Text line heights
ElementTheme.typography.fontBodyMdRegular.lineHeight.toDp() +
ElementTheme.typography.fontBodyLgRegular.lineHeight.toDp()
Row( Row(
modifier = Modifier modifier = Modifier
.niceClickable(onClick = { onSelectSearchHistoryResult(result) }) .clickable(onClick = { onSelectSearchHistoryResult(result) })
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(start = 16.dp, end = 2.dp)
.height(twoLineHeight),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
when (result) { when (result) {
is SearchHistoryResultItem.Query -> { is SearchHistoryListItem.Query -> {
Icon( Icon(
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
@@ -315,13 +347,14 @@ private fun LazyListScope.searchHistory(
) )
Text( Text(
modifier = Modifier.weight(1f, fill = true),
text = result.term, text = result.term,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
) )
} }
is SearchHistoryResultItem.Room -> { is SearchHistoryListItem.Room -> {
val heroAvatars = remember(result.roomInfo.heroes) { val heroAvatars = remember(result.roomInfo.heroes) {
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList() result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
} }
@@ -333,7 +366,7 @@ private fun LazyListScope.searchHistory(
), ),
) )
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.weight(1f, fill = true)) {
Text( Text(
text = result.roomInfo.name.orEmpty(), text = result.roomInfo.name.orEmpty(),
maxLines = 1, maxLines = 1,
@@ -365,6 +398,16 @@ private fun LazyListScope.searchHistory(
} }
} }
} }
Icon(
modifier = Modifier.minimumInteractiveComponentSize()
.niceClickable {
onRemoveResult(result)
},
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_remove),
tint = ElementTheme.colors.iconSecondary,
)
} }
} }
} }
@@ -451,7 +494,7 @@ private fun LazyListScope.roomListResults(
} }
private fun LazyListScope.messageSearchResults( private fun LazyListScope.messageSearchResults(
results: ImmutableList<MessageSearchResultItem>, results: ImmutableList<MessageSearchListItem>,
onSearchResultSelected: (RoomId, EventId?) -> Unit, onSearchResultSelected: (RoomId, EventId?) -> Unit,
) { ) {
if (results.isNotEmpty()) { if (results.isNotEmpty()) {
@@ -471,7 +514,7 @@ private fun LazyListScope.messageSearchResults(
) { index, result -> ) { index, result ->
val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
when (result) { when (result) {
is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( is MessageSearchListItem.Message -> TextMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
avatarType = AvatarType.Room( avatarType = AvatarType.Room(
@@ -482,7 +525,7 @@ private fun LazyListScope.messageSearchResults(
body = result.body, body = result.body,
formattedTimestamp = result.formattedTimestamp, formattedTimestamp = result.formattedTimestamp,
) )
is MessageSearchResultItem.Media -> { is MessageSearchListItem.Media -> {
MediaMessageSearchResultItemView( MediaMessageSearchResultItemView(
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
@@ -54,6 +54,17 @@ class DefaultSearchHistoryStore(
} }
} }
override suspend fun remove(result: SearchHistoryResult) {
withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
val updated = cache.value.filterNot { it == result }
cache.value = updated
persist(updated)
}
}
}
override suspend fun clear() = withContext<Unit>(dispatchers.io) { override suspend fun clear() = withContext<Unit>(dispatchers.io) {
mutex.withLock { mutex.withLock {
isLoaded = true isLoaded = true
@@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable
*/ */
@Serializable @Serializable
sealed interface SearchHistoryResult { sealed interface SearchHistoryResult {
@Serializable val id: String
data class Query(val term: String) : SearchHistoryResult
@Serializable @Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult data class Query(val term: String) : SearchHistoryResult {
override val id: String = term
}
@Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult {
override val id: String = roomId.value
}
} }
@@ -30,6 +30,11 @@ interface SearchHistoryStore {
*/ */
suspend fun add(result: SearchHistoryResult) suspend fun add(result: SearchHistoryResult)
/**
* Remove a [result] from the history.
*/
suspend fun remove(result: SearchHistoryResult)
/** /**
* Remove every entry from the history. * Remove every entry from the history.
*/ */
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Element Creations 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.home.impl.components
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.model.createRoomListRoomSummary
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import org.junit.Test
class RoomSummaryRowUnreadIndicatorCountTest {
@Test
fun `unread notifications are used when the room is not muted`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 3,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isEqualTo(3L)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(3L)
}
@Test
fun `unread notifications only show a dot when showUnreadCount is false`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 3,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isEqualTo(0L)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
}
@Test
fun `unread messages are used when there are no unread notifications and showAllActivity is true, but always display a dot`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 0,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
}
@Test
fun `unread messages are ignored when showAllActivity is false`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 0,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull()
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull()
}
@Test
fun `muted room ignores unread notifications and falls back to unread messages displaying a dot when showAllActivity is true`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 3,
numberOfUnreadMessages = 5,
userDefinedNotificationMode = RoomNotificationMode.MUTE,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull()
}
@Test
fun `no count when there are no unread notifications nor unread messages`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 0,
numberOfUnreadMessages = 0,
isMarkedUnread = true,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isNull()
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull()
}
}
@@ -39,6 +39,7 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -218,7 +219,7 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag // Skip intermediate event for loading the value of the feature flags
skipItems(1) skipItems(1)
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
@@ -265,7 +266,7 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag // Skip intermediate event for loading the value of the feature flags
skipItems(1) skipItems(1)
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
@@ -318,7 +319,7 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag // Skip intermediate event for loading the value of the feature flags
skipItems(1) skipItems(1)
eventRecorder.assertEmpty() eventRecorder.assertEmpty()
@@ -629,7 +630,7 @@ class RoomListPresenterTest {
) )
presenter.test { presenter.test {
assertThat(announcementService.announcementsToShowFlow().first()).isEmpty() assertThat(announcementService.announcementsToShowFlow().first()).isEmpty()
skipItems(2) // Skip initial state and intermediate event for loading the value of the `UnreadIndicatorCount` feature flag skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags
val state = awaitItem() val state = awaitItem()
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound)) announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound))
@@ -643,6 +644,51 @@ class RoomListPresenterTest {
} }
} }
@Test
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(aRoomSummary())),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)),
)
val matrixClient = FakeMatrixClient(
roomListService = FakeRoomListService(createRoomListLambda = { roomList }),
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.ShowAllActivityInRoomList.key to true,
FeatureFlags.UnreadIndicatorCount.key to true,
)
)
val presenter = createRoomListPresenter(
client = matrixClient,
featureFlagService = featureFlagService,
)
presenter.test {
val enabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms &&
state.contentAsRooms().showAllActivity &&
state.contentAsRooms().showUnreadCount
}.last()
assertThat(enabledState.contentAsRooms().showAllActivity).isTrue()
assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
val allActivityDisabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity
}.last()
assertThat(allActivityDisabledState.contentAsRooms().showAllActivity).isFalse()
assertThat(allActivityDisabledState.contentAsRooms().showUnreadCount).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.UnreadIndicatorCount, false)
val unreadCountDisabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showUnreadCount
}.last()
assertThat(unreadCountDisabledState.contentAsRooms().showAllActivity).isFalse()
assertThat(unreadCountDisabledState.contentAsRooms().showUnreadCount).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun createTestMarkRoomAsRead( private fun createTestMarkRoomAsRead(
client: MatrixClient, client: MatrixClient,
notificationCleaner: NotificationCleaner, notificationCleaner: NotificationCleaner,
@@ -224,7 +224,7 @@ class GlobalSearchPresenterTest {
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
assertThat(results).hasSize(1) assertThat(results).hasSize(1)
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -251,7 +251,7 @@ class GlobalSearchPresenterTest {
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
assertThat(results).hasSize(1) assertThat(results).hasSize(1)
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
// Remove the query // Remove the query
successState.queryState.edit { replace(0, length, "") } successState.queryState.edit { replace(0, length, "") }
@@ -337,6 +337,71 @@ class GlobalSearchPresenterTest {
} }
} }
@Test
fun `present - RemoveSearchHistoryResult removes a query entry`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply {
add(SearchHistoryResult.Query("keep"))
add(SearchHistoryResult.Query("drop"))
}
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("drop")))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - RemoveSearchHistoryResult removes a room entry`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply {
add(SearchHistoryResult.Query("keep"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - RemoveSearchHistoryResult with an unknown id does nothing`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply { add(SearchHistoryResult.Query("keep")) }
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("query:unknown")))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ClearSearchHistory removes every entry`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore().apply {
add(SearchHistoryResult.Query("a"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.ClearSearchHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest { fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore() val searchHistoryStore = FakeSearchHistoryStore()
@@ -345,7 +410,7 @@ class GlobalSearchPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo()))) initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
ensureAllEventsConsumed() ensureAllEventsConsumed()
@@ -361,7 +426,7 @@ class GlobalSearchPresenterTest {
// Skip loading search results state // Skip loading search results state
skipItems(1) skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test"))) initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Query("Test")))
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test") assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
assertThat(awaitItem().results.isLoading()).isTrue() assertThat(awaitItem().results.isLoading()).isTrue()
@@ -162,7 +162,7 @@ class GlobalSearchViewTest : RobolectricTest() {
results = AsyncData.Success( results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults( GlobalSearchResults.MessageSearchResults(
persistentListOf( persistentListOf(
MessageSearchResultItem.Message( MessageSearchListItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId), messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body", body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"), roomInfo = aRoomInfo(id = roomId, name = "A message room"),
@@ -184,7 +184,7 @@ class GlobalSearchViewTest : RobolectricTest() {
@Test @Test
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryResultItem.Query("Recent term") val queryResult = SearchHistoryListItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>() val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView( setGlobalSearchView(
state = aGlobalSearchState( state = aGlobalSearchState(
@@ -203,10 +203,47 @@ class GlobalSearchViewTest : RobolectricTest() {
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult)) eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
} }
@Test
fun `clicking on the remove button of a history result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryListItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(queryResult)),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithContentDescription(activity!!.getString(CommonStrings.action_remove)).performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.RemoveSearchHistoryResult(queryResult))
}
@Test
fun `clicking on clear all in the history - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(SearchHistoryListItem.Query("Recent term"))),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("Clear all").performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.ClearSearchHistory)
}
@Test @Test
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest { fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org") val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room( val roomResult = SearchHistoryListItem.Room(
roomId = roomId, roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"), roomInfo = aRoomInfo(id = roomId, name = "A history room"),
) )
@@ -89,6 +89,31 @@ class DefaultSearchHistoryStoreTest {
).inOrder() ).inOrder()
} }
@Test
fun `remove deletes only the given entry and persists the change`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
val store = createStore(fileStore)
store.add(SearchHistoryResult.Query("a"))
store.add(SearchHistoryResult.Room(A_ROOM_ID))
store.add(SearchHistoryResult.Query("b"))
store.remove(SearchHistoryResult.Room(A_ROOM_ID))
val expected = listOf(SearchHistoryResult.Query("b"), SearchHistoryResult.Query("a"))
assertThat(store.history.first()).containsExactlyElementsIn(expected).inOrder()
assertThat(createStore(fileStore).history.first()).containsExactlyElementsIn(expected).inOrder()
}
@Test
fun `remove of an unknown entry leaves the history unchanged`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("a"))
store.remove(SearchHistoryResult.Query("unknown"))
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Query("a"))
}
@Test @Test
fun `clear removes every entry and deletes the persisted content`() = runTest { fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore() val fileStore = FakeSearchHistoryFileStore()
@@ -22,6 +22,10 @@ class FakeSearchHistoryStore : SearchHistoryStore {
}.take(MAX_SEARCH_HISTORY_SIZE) }.take(MAX_SEARCH_HISTORY_SIZE)
} }
override suspend fun remove(result: SearchHistoryResult) {
state.value = state.value.filterNot { it == result }
}
override suspend fun clear() { override suspend fun clear() {
state.value = emptyList() state.value = emptyList()
} }
@@ -8,7 +8,6 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -650,8 +649,9 @@ class MessagesPresenter(
event.eventId ?: return event.eventId ?: return
room.getPermalinkFor(event.eventId).fold( room.getPermalinkFor(event.eventId).fold(
onSuccess = { permalink -> onSuccess = { permalink ->
clipboardHelper.copyPlainText(permalink) clipboardHelper.copyPlainText(permalink) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
}
}, },
onFailure = { onFailure = {
Timber.e(it, "Failed to get permalink for event ${event.eventId}") Timber.e(it, "Failed to get permalink for event ${event.eventId}")
@@ -666,16 +666,14 @@ class MessagesPresenter(
is TimelineItemStateContent -> event.content.body is TimelineItemStateContent -> event.content.body
else -> return else -> return
} }
clipboardHelper.copyPlainText(content) clipboardHelper.copyPlainText(content) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied)) snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
} }
} }
private fun handleCopyCaption(event: TimelineItem.Event) { private fun handleCopyCaption(event: TimelineItem.Event) {
val content = event.content.captionOrNull() ?: return val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content) clipboardHelper.copyPlainText(content) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
} }
} }
@@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@@ -26,8 +25,9 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -53,6 +53,8 @@ class PinnedMessagesListNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
@@ -114,7 +116,6 @@ class PinnedMessagesListNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current val view = LocalView.current
val state = presenter.present() val state = presenter.present()
PinnedMessagesListView( PinnedMessagesListView(
@@ -132,10 +133,9 @@ class PinnedMessagesListNode(
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS HapticFeedbackConstants.LONG_PRESS
) )
context.copyToClipboard( clipboardHelper.copyPlainText(it.url) {
text = it.url, toastHelper.show(CommonStrings.common_copied_to_clipboard)
toastMessage = toastMessage, }
)
}, },
emojiPickerRenderer = emojiPickerRenderer, emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier modifier = modifier
@@ -267,6 +267,7 @@ private fun ThreadListItemRow(
UnreadIndicatorAtom( UnreadIndicatorAtom(
size = 14.dp, size = 14.dp,
isVisible = hasUnreadNotifications, isVisible = hasUnreadNotifications,
count = 0,
) )
} }
} }
@@ -28,10 +28,10 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent data object MarkAllAsRead : TimelineEvent
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
data class ToggleSelection(val eventId: EventId) : TimelineEvent data class ToggleSelection(val eventId: EventId) : TimelineEvent
data object ExitSelectionMode : TimelineEvent data object ExitSelectionMode : TimelineEvent
data class CopyToClipboard(val text: String) : TimelineEvent
/** /**
* Events coming from a timeline item. * Events coming from a timeline item.
@@ -44,6 +44,8 @@ import io.element.android.features.messages.impl.voicemessages.timeline.Redacted
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -112,6 +114,8 @@ class TimelinePresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@@ -355,6 +359,11 @@ class TimelinePresenter(
is TimelineEvent.ValidateMedia -> { is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState)) timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
} }
is TimelineEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
} }
} }
@@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
@@ -96,7 +95,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -171,8 +169,6 @@ fun TimelineView(
state.eventSink(TimelineEvent.FocusOnEvent(eventId)) state.eventSink(TimelineEvent.FocusOnEvent(eventId))
} }
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current val view = LocalView.current
fun inReplyToClick(eventId: EventId) { fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId)) state.eventSink(TimelineEvent.FocusOnEvent(eventId))
@@ -182,10 +178,7 @@ fun TimelineView(
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS HapticFeedbackConstants.LONG_PRESS
) )
context.copyToClipboard( state.eventSink(TimelineEvent.CopyToClipboard(link.url))
text = link.url,
toastMessage = toastMessage,
)
} }
fun prefetchMoreItems() { fun prefetchMoreItems() {
@@ -606,6 +599,7 @@ private fun JumpToPositionButton(
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) }, .offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary, color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0,
) )
} }
} }
@@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
class EventDebugInfoNode( class EventDebugInfoNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val eventId: EventId?, val eventId: EventId?,
@@ -39,6 +44,12 @@ class EventDebugInfoNode(
navigateUp() navigateUp()
} }
private fun onCopyText(text: String) {
clipboardHelper.copyPlainText(text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
@Composable @Composable
override fun View(modifier: Modifier) = with(inputs) { override fun View(modifier: Modifier) = with(inputs) {
EventDebugInfoView( EventDebugInfoView(
@@ -46,7 +57,8 @@ class EventDebugInfoNode(
model = timelineItemDebugInfo.model, model = timelineItemDebugInfo.model,
originalJson = timelineItemDebugInfo.originalJson, originalJson = timelineItemDebugInfo.originalJson,
latestEditedJson = timelineItemDebugInfo.latestEditedJson, latestEditedJson = timelineItemDebugInfo.latestEditedJson,
onBackClick = ::onBackClick onBackClick = ::onBackClick,
onCopyText = ::onCopyText,
) )
} }
} }
@@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline.debug package io.element.android.features.messages.impl.timeline.debug
import android.content.ClipData
import android.content.ClipboardManager
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
@@ -34,11 +32,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@@ -63,6 +59,7 @@ fun EventDebugInfoView(
originalJson: String?, originalJson: String?,
latestEditedJson: String?, latestEditedJson: String?,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onCopyText: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTest: Boolean = false, isTest: Boolean = false,
) { ) {
@@ -87,20 +84,30 @@ fun EventDebugInfoView(
item { item {
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Event ID:") Text(text = "Event ID:")
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth()) CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth())
} }
} }
item { item {
CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded) CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded)
} }
if (originalJson != null) { if (originalJson != null) {
item { item {
CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded) CollapsibleSection(
title = "Original JSON:",
text = prettyJSON(originalJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
} }
} }
if (latestEditedJson != null) { if (latestEditedJson != null) {
item { item {
CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded) CollapsibleSection(
title = "Latest edited JSON:",
text = prettyJSON(latestEditedJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
} }
} }
} }
@@ -120,6 +127,7 @@ private fun prettyJSON(maybeJSON: String): String {
private fun CollapsibleSection( private fun CollapsibleSection(
title: String, title: String,
text: String, text: String,
onCopyText: (String) -> Unit,
initiallyExpanded: Boolean = false, initiallyExpanded: Boolean = false,
) { ) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) } var isExpanded by remember { mutableStateOf(initiallyExpanded) }
@@ -139,7 +147,7 @@ private fun CollapsibleSection(
) )
} }
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
CopyableText(text = text, modifier = Modifier.fillMaxWidth()) CopyableText(text = text, onClick = onCopyText, modifier = Modifier.fillMaxWidth())
} }
} }
} }
@@ -147,16 +155,15 @@ private fun CollapsibleSection(
@Composable @Composable
private fun CopyableText( private fun CopyableText(
text: String, text: String,
onClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val clipboardManager = remember { requireNotNull(context.getSystemService<ClipboardManager>()) }
Box( Box(
modifier modifier
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(6.dp) .padding(6.dp)
.clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) } .clickable { onClick(text) }
) { ) {
Text( Text(
text = text, text = text,
@@ -174,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview {
model = "Rust(\n\tModel()\n)", model = "Rust(\n\tModel()\n)",
originalJson = "{\"name\": \"original\"}", originalJson = "{\"name\": \"original\"}",
latestEditedJson = "{\"name\": \"edited\"}", latestEditedJson = "{\"name\": \"edited\"}",
onBackClick = { } onBackClick = { },
onCopyText = { },
) )
} }
@@ -33,6 +33,10 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -74,6 +78,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
@@ -1543,6 +1548,38 @@ class TimelinePresenterTest {
} }
} }
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createTimelinePresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createTimelinePresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - show shield hide shield`() = runTest { fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter() val presenter = createTimelinePresenter()
@@ -2045,6 +2082,8 @@ class TimelinePresenterTest {
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() }, timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -2072,6 +2111,8 @@ class TimelinePresenterTest {
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead, markAsFullyRead = markAsFullyRead,
timelineProtectionPresenter = timelineProtectionPresenter, timelineProtectionPresenter = timelineProtectionPresenter,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -81,8 +81,9 @@ class BlockedUsersPresenter(
unblockUserAction.value = AsyncAction.ConfirmingNoParams unblockUserAction.value = AsyncAction.ConfirmingNoParams
} }
is BlockedUsersEvent.CopyToClipboard -> { is BlockedUsersEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.userId.value) clipboardHelper.copyPlainText(event.userId.value) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
} }
BlockedUsersEvent.ConfirmUnblock -> { BlockedUsersEvent.ConfirmUnblock -> {
pendingUserToUnblock?.let { pendingUserToUnblock?.let {
@@ -19,4 +19,5 @@ sealed interface DeveloperSettingsEvent {
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
data object OpenPushRules : DeveloperSettingsEvent data object OpenPushRules : DeveloperSettingsEvent
data object DismissPushRulesError : DeveloperSettingsEvent data object DismissPushRulesError : DeveloperSettingsEvent
data class CopyToClipboard(val text: String) : DeveloperSettingsEvent
} }
@@ -26,7 +26,9 @@ import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -61,6 +64,8 @@ class DeveloperSettingsPresenter(
private val markAllRoomsAsRead: MarkAllRoomsAsRead, private val markAllRoomsAsRead: MarkAllRoomsAsRead,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@@ -130,6 +135,11 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvent.DismissPushRulesError -> { DeveloperSettingsEvent.DismissPushRulesError -> {
pushRulesAction.value = AsyncAction.Uninitialized pushRulesAction.value = AsyncAction.Uninitialized
} }
is DeveloperSettingsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
} }
} }
@@ -15,14 +15,12 @@ import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@@ -84,7 +82,10 @@ fun DeveloperSettingsView(
AppDeveloperSettingsView( AppDeveloperSettingsView(
state = state.appDeveloperSettingsState, state = state.appDeveloperSettingsState,
) )
SessionCategory(deviceId = state.deviceId) SessionCategory(
deviceId = state.deviceId,
onCopyToClipboard = { state.eventSink(DeveloperSettingsEvent.CopyToClipboard(it)) },
)
NotificationCategory( NotificationCategory(
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) }, onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
onPushHistoryClick = onPushHistoryClick, onPushHistoryClick = onPushHistoryClick,
@@ -182,19 +183,15 @@ fun DeveloperSettingsView(
} }
@Composable @Composable
private fun SessionCategory(deviceId: DeviceId) { private fun SessionCategory(
deviceId: DeviceId,
onCopyToClipboard: (String) -> Unit,
) {
PreferenceCategory(title = "Session") { PreferenceCategory(title = "Session") {
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val context = LocalContext.current
ListItem( ListItem(
content = { Text("DeviceId") }, content = { Text("DeviceId") },
supportingContent = { Text(text = deviceId.value) }, supportingContent = { Text(text = deviceId.value) },
onClick = { onClick = { onCopyToClipboard(deviceId.value) }
context.copyToClipboard(
text = deviceId.value,
toastMessage = toastMessage,
)
}
) )
} }
} }
@@ -17,4 +17,5 @@ sealed interface AppDeveloperSettingsEvent {
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent
data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent
data class CopyToClipboard(val text: String) : AppDeveloperSettingsEvent
} }
@@ -22,6 +22,8 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.model.EnabledFeature import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -29,6 +31,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -44,6 +47,8 @@ class AppDeveloperSettingsPresenter(
private val rageshakePresenter: Presenter<RageshakePreferencesState>, private val rageshakePresenter: Presenter<RageshakePreferencesState>,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<AppDeveloperSettingsState> { ) : Presenter<AppDeveloperSettingsState> {
@Composable @Composable
override fun present(): AppDeveloperSettingsState { override fun present(): AppDeveloperSettingsState {
@@ -106,6 +111,11 @@ class AppDeveloperSettingsPresenter(
} }
appPreferencesStore.setTracingLogPacks(currentPacks) appPreferencesStore.setTracingLogPacks(currentPacks)
} }
is AppDeveloperSettingsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
} }
} }
@@ -14,7 +14,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -23,7 +22,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.components.list.ListItemContent 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.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
@@ -108,6 +106,7 @@ fun AppDeveloperSettingsView(
GitCategory( GitCategory(
gitBranch = state.gitBranch, gitBranch = state.gitBranch,
gitSha = state.gitSha, gitSha = state.gitSha,
onCopyToClipboard = { state.eventSink(AppDeveloperSettingsEvent.CopyToClipboard(it)) },
) )
} }
} }
@@ -116,29 +115,18 @@ fun AppDeveloperSettingsView(
private fun GitCategory( private fun GitCategory(
gitBranch: String, gitBranch: String,
gitSha: String, gitSha: String,
onCopyToClipboard: (String) -> Unit,
) { ) {
PreferenceCategory(title = "Git") { PreferenceCategory(title = "Git") {
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val context = LocalContext.current
ListItem( ListItem(
content = { Text("Git branch") }, content = { Text("Git branch") },
supportingContent = { Text(text = gitBranch) }, supportingContent = { Text(text = gitBranch) },
onClick = { onClick = { onCopyToClipboard(gitBranch) }
context.copyToClipboard(
text = gitBranch,
toastMessage = toastMessage,
)
}
) )
ListItem( ListItem(
content = { Text("Git SHA") }, content = { Text("Git SHA") },
supportingContent = { Text(text = gitSha) }, supportingContent = { Text(text = gitSha) },
onClick = { onClick = { onCopyToClipboard(gitSha) }
context.copyToClipboard(
text = gitSha,
toastMessage = toastMessage,
)
}
) )
} }
} }
@@ -34,4 +34,5 @@ sealed interface NotificationSettingsEvent {
data object DismissCallRingtoneDialog : NotificationSettingsEvent data object DismissCallRingtoneDialog : NotificationSettingsEvent
data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent
data object LaunchCallRingtonePicker : NotificationSettingsEvent data object LaunchCallRingtonePicker : NotificationSettingsEvent
data object ToggleShowAllActivityInRoomList : NotificationSettingsEvent
} }
@@ -28,6 +28,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
@@ -73,6 +75,7 @@ class NotificationSettingsPresenter(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
) : Presenter<NotificationSettingsState> { ) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected; // its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
@@ -196,6 +199,11 @@ class NotificationSettingsPresenter(
) )
} }
val showAllActivityInRoomList by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList)
.collect { value = it }
}
fun handleEvent(event: NotificationSettingsEvent) { fun handleEvent(event: NotificationSettingsEvent) {
when (event) { when (event) {
is NotificationSettingsEvent.SetAtRoomNotificationsEnabled -> { is NotificationSettingsEvent.SetAtRoomNotificationsEnabled -> {
@@ -276,6 +284,9 @@ class NotificationSettingsPresenter(
NotificationSettingsEvent.DismissCallRingtoneCopyError -> { NotificationSettingsEvent.DismissCallRingtoneCopyError -> {
callRingtoneCopyError = false callRingtoneCopyError = false
} }
NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch {
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, !showAllActivityInRoomList)
}
} }
} }
@@ -304,6 +315,7 @@ class NotificationSettingsPresenter(
showCallRingtoneDialog = showCallRingtoneDialog, showCallRingtoneDialog = showCallRingtoneDialog,
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch, pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch, pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
showAllActivityInRoomList = showAllActivityInRoomList,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -42,6 +42,10 @@ data class NotificationSettingsState(
* auto-opens on screen entry. * auto-opens on screen entry.
*/ */
val pendingCallRingtonePickerLaunch: Int, val pendingCallRingtonePickerLaunch: Int,
/**
* Whether to show all activity indicators in the room list, using unread messages instead of notifications.
*/
val showAllActivityInRoomList: Boolean,
val eventSink: (NotificationSettingsEvent) -> Unit, val eventSink: (NotificationSettingsEvent) -> Unit,
) { ) {
sealed interface MatrixSettings { sealed interface MatrixSettings {
@@ -99,6 +99,7 @@ fun aValidNotificationSettingsState(
callRingtoneCopyError: Boolean = false, callRingtoneCopyError: Boolean = false,
showMessageSoundDialog: Boolean = false, showMessageSoundDialog: Boolean = false,
showCallRingtoneDialog: Boolean = false, showCallRingtoneDialog: Boolean = false,
showAllActivityInRoomList: Boolean = false,
pendingMessageSoundPickerLaunch: Int = 0, pendingMessageSoundPickerLaunch: Int = 0,
pendingCallRingtonePickerLaunch: Int = 0, pendingCallRingtonePickerLaunch: Int = 0,
eventSink: (NotificationSettingsEvent) -> Unit = {}, eventSink: (NotificationSettingsEvent) -> Unit = {},
@@ -133,6 +134,7 @@ fun aValidNotificationSettingsState(
showCallRingtoneDialog = showCallRingtoneDialog, showCallRingtoneDialog = showCallRingtoneDialog,
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch, pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch, pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
showAllActivityInRoomList = showAllActivityInRoomList,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -166,6 +168,7 @@ fun aInvalidNotificationSettingsState(
showCallRingtoneDialog = false, showCallRingtoneDialog = false,
pendingMessageSoundPickerLaunch = 0, pendingMessageSoundPickerLaunch = 0,
pendingCallRingtonePickerLaunch = 0, pendingCallRingtonePickerLaunch = 0,
showAllActivityInRoomList = false,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -199,6 +199,16 @@ private fun NotificationSettingsContentView(
onCheckedChange = onMentionNotificationsChange onCheckedChange = onMentionNotificationsChange
) )
} }
PreferenceCategory {
PreferenceSwitch(
title = stringResource(R.string.screen_notification_settings_show_all_activity_title),
subtitle = stringResource(R.string.screen_notification_settings_show_all_activity_subtitle),
isChecked = state.showAllActivityInRoomList,
onCheckedChange = { state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) }
)
}
SoundsPreferenceCategory(state = state) SoundsPreferenceCategory(state = state)
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) {
// TODO We are removing the call notification toggle until support for call notifications has been added // TODO We are removing the call notification toggle until support for call notifications has been added
@@ -93,7 +93,6 @@
<string name="screen_notification_settings_mode_mentions">"Згадки"</string> <string name="screen_notification_settings_mode_mentions">"Згадки"</string>
<string name="screen_notification_settings_notification_section_title">"Сповіщати мене про"</string> <string name="screen_notification_settings_notification_section_title">"Сповіщати мене про"</string>
<string name="screen_notification_settings_room_mention_label">"Згадки @room"</string> <string name="screen_notification_settings_room_mention_label">"Згадки @room"</string>
<string name="screen_notification_settings_show_all_activity">"Показати всю діяльність у списку кімнат (крапки або кількість непрочитаних повідомлень)"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Власний"</string> <string name="screen_notification_settings_sound_custom_fallback">"Власний"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Користувацький звук…"</string> <string name="screen_notification_settings_sound_custom_sound_button_title">"Користувацький звук…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Помилка видалення файлу"</string> <string name="screen_notification_settings_sound_delete_sound_error_title">"Помилка видалення файлу"</string>
@@ -91,7 +91,6 @@
<string name="screen_notification_settings_mode_mentions">"提及"</string> <string name="screen_notification_settings_mode_mentions">"提及"</string>
<string name="screen_notification_settings_notification_section_title">"通知我以下类型"</string> <string name="screen_notification_settings_notification_section_title">"通知我以下类型"</string>
<string name="screen_notification_settings_room_mention_label">"含有 @room 的提及"</string> <string name="screen_notification_settings_room_mention_label">"含有 @room 的提及"</string>
<string name="screen_notification_settings_show_all_activity">"在房间列表上显示所有活动(用于未读消息的圆点或数字)"</string>
<string name="screen_notification_settings_sound_custom_fallback">"自定义"</string> <string name="screen_notification_settings_sound_custom_fallback">"自定义"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"自定义提示音…"</string> <string name="screen_notification_settings_sound_custom_sound_button_title">"自定义提示音…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"删除文件时出错"</string> <string name="screen_notification_settings_sound_delete_sound_error_title">"删除文件时出错"</string>
@@ -92,7 +92,8 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_mode_mentions">"Mentions"</string> <string name="screen_notification_settings_mode_mentions">"Mentions"</string>
<string name="screen_notification_settings_notification_section_title">"Notify me for"</string> <string name="screen_notification_settings_notification_section_title">"Notify me for"</string>
<string name="screen_notification_settings_room_mention_label">"Mentions of @room"</string> <string name="screen_notification_settings_room_mention_label">"Mentions of @room"</string>
<string name="screen_notification_settings_show_all_activity">"Show all activity in the room list (dots or number of unread messages)"</string> <string name="screen_notification_settings_show_all_activity_subtitle">"Dots or number of unread messages"</string>
<string name="screen_notification_settings_show_all_activity_title">"Show all activity in the room list"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Custom"</string> <string name="screen_notification_settings_sound_custom_fallback">"Custom"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Custom sound…"</string> <string name="screen_notification_settings_sound_custom_sound_button_title">"Custom sound…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Error deleting file"</string> <string name="screen_notification_settings_sound_delete_sound_error_title">"Error deleting file"</string>
@@ -19,7 +19,11 @@ import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.core.data.megaBytes
@@ -33,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -250,6 +255,38 @@ class DeveloperSettingsPresenterTest {
} }
} }
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private fun createDeveloperSettingsPresenter( private fun createDeveloperSettingsPresenter(
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() }, navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
sessionId: SessionId = A_SESSION_ID, sessionId: SessionId = A_SESSION_ID,
@@ -262,6 +299,8 @@ class DeveloperSettingsPresenterTest {
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): DeveloperSettingsPresenter { ): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter( return DeveloperSettingsPresenter(
navigator = navigator, navigator = navigator,
@@ -277,6 +316,8 @@ class DeveloperSettingsPresenterTest {
markAllRoomsAsRead = markAllRoomsAsRead, markAllRoomsAsRead = markAllRoomsAsRead,
buildMeta = buildMeta, buildMeta = buildMeta,
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -12,12 +12,17 @@ package io.element.android.features.preferences.impl.developer.appsettings
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@@ -151,6 +156,38 @@ class AppDeveloperSettingsPresenterTest {
} }
} }
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createAppDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createAppDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private fun createAppDeveloperSettingsPresenter( private fun createAppDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
getAvailableFeaturesResult = { _, _ -> getAvailableFeaturesResult = { _, _ ->
@@ -164,6 +201,8 @@ class AppDeveloperSettingsPresenterTest {
} }
), ),
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): AppDeveloperSettingsPresenter { ): AppDeveloperSettingsPresenter {
return AppDeveloperSettingsPresenter( return AppDeveloperSettingsPresenter(
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
@@ -172,7 +211,9 @@ class AppDeveloperSettingsPresenterTest {
buildMeta = aBuildMeta( buildMeta = aBuildMeta(
gitRevision = "1234567890", gitRevision = "1234567890",
gitBranchName = "feature/awesome-feature" gitBranchName = "feature/awesome-feature"
) ),
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -10,6 +10,9 @@ package io.element.android.features.preferences.impl.notifications
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -1104,6 +1107,46 @@ class NotificationSettingsPresenterTest {
} }
} }
@Test
fun `present - showAllActivityInRoomList reflects the feature flag value`() = runTest {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to true)
)
val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService)
presenter.test {
val state = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last()
assertThat(state.showAllActivityInRoomList).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
val updatedState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last()
assertThat(updatedState.showAllActivityInRoomList).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ToggleShowAllActivityInRoomList toggles the feature flag`() = runTest {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to false)
)
val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.showAllActivityInRoomList).isFalse()
initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList)
val enabledState = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last()
assertThat(enabledState.showAllActivityInRoomList).isTrue()
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isTrue()
enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList)
val disabledState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last()
assertThat(disabledState.showAllActivityInRoomList).isFalse()
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createNotificationSettingsPresenter( private fun TestScope.createNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
pushService: PushService = FakePushService(), pushService: PushService = FakePushService(),
@@ -1116,6 +1159,7 @@ class NotificationSettingsPresenterTest {
NotificationSoundCopier.CopyResult.Success(source, source) NotificationSoundCopier.CopyResult.Success(source, source)
}), }),
soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(), soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): NotificationSettingsPresenter { ): NotificationSettingsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
return NotificationSettingsPresenter( return NotificationSettingsPresenter(
@@ -1131,6 +1175,7 @@ class NotificationSettingsPresenterTest {
soundDisplayNameResolver = soundDisplayNameResolver, soundDisplayNameResolver = soundDisplayNameResolver,
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
featureFlagService = featureFlagService,
) )
} }
} }
@@ -183,6 +183,35 @@ class NotificationSettingsViewTest : RobolectricTest() {
) )
} }
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on disable show all activity emits the expected events`() {
testShowAllActivityToggle(true)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on enable show all activity emits the expected events`() {
testShowAllActivityToggle(false)
}
private fun testShowAllActivityToggle(initialState: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvent>()
setNotificationSettingsView(
state = aValidNotificationSettingsState(
showAllActivityInRoomList = initialState,
eventSink = eventsRecorder
),
)
clickOn(R.string.screen_notification_settings_show_all_activity_title)
eventsRecorder.assertList(
listOf(
NotificationSettingsEvent.RefreshSystemNotificationsEnabled,
NotificationSettingsEvent.ToggleShowAllActivityInRoomList,
)
)
}
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest { fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest {
@@ -13,6 +13,7 @@ sealed interface RoomDetailsEvent {
data object MuteNotification : RoomDetailsEvent data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent
data class CopyToClipboard(val text: String) : RoomDetailsEvent data class CopyToClipboard(val text: String) : RoomDetailsEvent
data object CopyRoomId : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
data object MarkAsRead : RoomDetailsEvent data object MarkAsRead : RoomDetailsEvent
data object MarkAsUnread : RoomDetailsEvent data object MarkAsUnread : RoomDetailsEvent
@@ -32,6 +32,7 @@ import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermission
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.LocalSnackbarDispatcher
@@ -74,6 +75,7 @@ class RoomDetailsPresenter(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
@@ -167,8 +169,14 @@ class RoomDetailsPresenter(
} }
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
is RoomDetailsEvent.CopyToClipboard -> { is RoomDetailsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) clipboardHelper.copyPlainText(event.text) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
}
is RoomDetailsEvent.CopyRoomId -> {
clipboardHelper.copyPlainText(room.roomId.value) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
} }
is RoomDetailsEvent.MarkAsRead -> scope.markAsRead() is RoomDetailsEvent.MarkAsRead -> scope.markAsRead()
is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread() is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread()
@@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -48,7 +47,6 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule
@@ -339,6 +337,7 @@ fun RoomDetailsView(
DebugInfoSection( DebugInfoSection(
roomId = state.roomId, roomId = state.roomId,
roomVersion = state.roomVersion, roomVersion = state.roomVersion,
onCopyRoomIdClick = { state.eventSink(RoomDetailsEvent.CopyRoomId) },
) )
} }
} }
@@ -865,10 +864,9 @@ private fun OtherActionsSection(
private fun DebugInfoSection( private fun DebugInfoSection(
roomId: RoomId, roomId: RoomId,
roomVersion: String?, roomVersion: String?,
onCopyRoomIdClick: () -> Unit,
) { ) {
val context = LocalContext.current
PreferenceCategory { PreferenceCategory {
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
ListItem( ListItem(
content = { content = {
Text("Internal room ID") Text("Internal room ID")
@@ -882,12 +880,7 @@ private fun DebugInfoSection(
}, },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())),
onClick = { onClick = onCopyRoomIdClick,
context.copyToClipboard(
text = roomId.value,
toastMessage = toastMessage,
)
},
) )
ListItem( ListItem(
content = { content = {
@@ -118,8 +118,9 @@ class RoomMemberDetailsPresenter(
encryptionService.withdrawVerification(roomMemberId) encryptionService.withdrawVerification(roomMemberId)
} }
is UserProfileEvent.CopyToClipboard -> { is UserProfileEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) clipboardHelper.copyPlainText(event.text) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
} }
else -> userProfileState.eventSink(event) else -> userProfileState.eventSink(event)
} }
@@ -19,6 +19,8 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -49,6 +51,7 @@ import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@@ -85,6 +88,7 @@ class RoomDetailsPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(), encryptionService: FakeEncryptionService = FakeEncryptionService(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(), clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(), navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(),
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
@@ -115,12 +119,53 @@ class RoomDetailsPresenterTest {
dispatchers = dispatchers, dispatchers = dispatchers,
analyticsService = analyticsService, analyticsService = analyticsService,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )
} }
@Test
fun `present - copy room id on old device shows a toast`() = runTest {
val room = aJoinedRoom(
roomPermissions = roomPermissions(),
)
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createRoomDetailsPresenter(
room = room,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.CopyRoomId)
assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value)
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy room id on new device does not show a toast`() = runTest {
val room = aJoinedRoom(
roomPermissions = roomPermissions(),
)
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createRoomDetailsPresenter(
room = room,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.CopyRoomId)
assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value)
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - initial state is created from initial room info`() = runTest { fun `present - initial state is created from initial room info`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
@@ -9,14 +9,21 @@
package io.element.android.features.securebackup.impl.reset.password package io.element.android.features.securebackup.impl.reset.password
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -42,6 +49,11 @@ fun ResetIdentityPasswordView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val passwordState = textFieldState(stateValue = "") val passwordState = textFieldState(stateValue = "")
val submit = {
if (passwordState.value.isNotEmpty()) {
state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value))
}
}
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()), iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
@@ -58,13 +70,14 @@ fun ResetIdentityPasswordView(
passwordState.value = newText passwordState.value = newText
}, },
hasError = state.resetAction.isFailure(), hasError = state.resetAction.isFailure(),
onSubmit = submit,
) )
}, },
buttons = { buttons = {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_reset_identity), text = stringResource(CommonStrings.action_reset_identity),
onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) }, onClick = submit,
destructive = true, destructive = true,
enabled = passwordState.value.isNotEmpty(), enabled = passwordState.value.isNotEmpty(),
) )
@@ -78,12 +91,20 @@ fun ResetIdentityPasswordView(
} }
@Composable @Composable
private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) { private fun Content(
text: String,
onTextChange: (String) -> Unit,
hasError: Boolean,
onSubmit: () -> Unit,
) {
var showPassword by remember { mutableStateOf(false) } var showPassword by remember { mutableStateOf(false) }
TextField( TextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(LocalFocusManager.current), .onTabOrEnterKeyFocusNext(LocalFocusManager.current)
.semantics {
contentType = ContentType.Password
},
value = text, value = text,
onValueChange = onTextChange, onValueChange = onTextChange,
placeholder = stringResource(CommonStrings.common_password), placeholder = stringResource(CommonStrings.common_password),
@@ -95,6 +116,13 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool
onToggle = { showPassword = !showPassword }, onToggle = { showPassword = !showPassword },
) )
}, },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onSubmit() }
),
validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None, validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None,
supportingText = if (hasError) { supportingText = if (hasError) {
stringResource(R.string.screen_reset_encryption_password_error) stringResource(R.string.screen_reset_encryption_password_error)
@@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup
sealed interface SecureBackupSetupEvent { sealed interface SecureBackupSetupEvent {
data object CreateRecoveryKey : SecureBackupSetupEvent data object CreateRecoveryKey : SecureBackupSetupEvent
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent
data object CopyRecoveryKey : SecureBackupSetupEvent
data object Done : SecureBackupSetupEvent data object Done : SecureBackupSetupEvent
data object DismissDialog : SecureBackupSetupEvent data object DismissDialog : SecureBackupSetupEvent
} }
@@ -22,9 +22,12 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.loggerTagSetup import io.element.android.features.securebackup.impl.loggerTagSetup
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -38,6 +41,8 @@ class SecureBackupSetupPresenter(
@Assisted private val isChangeRecoveryKeyUserStory: Boolean, @Assisted private val isChangeRecoveryKeyUserStory: Boolean,
private val stateMachine: SecureBackupSetupStateMachine, private val stateMachine: SecureBackupSetupStateMachine,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<SecureBackupSetupState> { ) : Presenter<SecureBackupSetupState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -60,6 +65,13 @@ class SecureBackupSetupPresenter(
} }
SecureBackupSetupEvent.RecoveryKeyHasBeenSaved -> SecureBackupSetupEvent.RecoveryKeyHasBeenSaved ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
SecureBackupSetupEvent.CopyRecoveryKey -> {
val recoveryKey = setupState.recoveryKey() ?: return
clipboardHelper.copyPlainText(recoveryKey, isSensitive = true) {
toastHelper.show(R.string.screen_recovery_key_copied_to_clipboard)
}
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
}
SecureBackupSetupEvent.DismissDialog -> { SecureBackupSetupEvent.DismissDialog -> {
showSaveConfirmationDialog = false showSaveConfirmationDialog = false
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError) stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError)
@@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
@@ -114,16 +113,10 @@ private fun subtitle(state: SecureBackupSetupState): String {
private fun Content( private fun Content(
state: SecureBackupSetupState, state: SecureBackupSetupState,
) { ) {
val context = LocalContext.current
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
val clickLambda = if (formattedRecoveryKey != null) { val clickLambda = if (formattedRecoveryKey != null) {
{ {
context.copyToClipboard( state.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
text = formattedRecoveryKey,
toastMessage = toastMessage,
)
state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved)
} }
} else { } else {
if (!state.recoveryKeyViewState.inProgress) { if (!state.recoveryKeyViewState.inProgress) {
@@ -15,6 +15,8 @@ import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@@ -40,6 +42,8 @@ class DefaultSecureBackupSetupEntryPointTest {
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(), stateMachine = SecureBackupSetupStateMachine(),
encryptionService = FakeEncryptionService(), encryptionService = FakeEncryptionService(),
clipboardHelper = FakeClipboardHelper(),
toastHelper = FakeToastHelper(),
) )
}, },
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
@@ -12,8 +12,13 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
@@ -186,16 +191,70 @@ class SecureBackupSetupPresenterTest {
} }
} }
@Test
fun `present - copy recovery key on old device shows a toast`() = runTest {
val encryptionService = FakeEncryptionService()
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey)
skipItems(1)
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
val createdState = awaitItem()
createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY)
assertThat(clipboardHelper.isSensitive).isTrue()
assertThat(toastHelper.shownToasts).containsExactly(R.string.screen_recovery_key_copied_to_clipboard)
assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
}
}
@Test
fun `present - copy recovery key on new device does not show a toast`() = runTest {
val encryptionService = FakeEncryptionService()
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey)
skipItems(1)
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
val createdState = awaitItem()
createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY)
assertThat(clipboardHelper.isSensitive).isTrue()
assertThat(toastHelper.shownToasts).isEmpty()
assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
}
}
private fun createSecureBackupSetupPresenter( private fun createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory: Boolean = false, isChangeRecoveryKeyUserStory: Boolean = false,
encryptionService: EncryptionService = FakeEncryptionService( encryptionService: EncryptionService = FakeEncryptionService(
enableRecoveryLambda = { _, _ -> Result.success("") }, enableRecoveryLambda = { _, _ -> Result.success("") },
), ),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): SecureBackupSetupPresenter { ): SecureBackupSetupPresenter {
return SecureBackupSetupPresenter( return SecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(), stateMachine = SecureBackupSetupStateMachine(),
encryptionService = encryptionService, encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
api(libs.statemachine) api(libs.statemachine)
@@ -44,4 +45,5 @@ dependencies {
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
} }
@@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -26,13 +27,17 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -45,6 +50,8 @@ class IncomingVerificationPresenter(
@Assisted private val navigator: IncomingVerificationNavigator, @Assisted private val navigator: IncomingVerificationNavigator,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val sessionId: SessionId,
private val sessionStore: SessionStore,
private val stateMachine: IncomingVerificationStateMachine, private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
) : Presenter<IncomingVerificationState> { ) : Presenter<IncomingVerificationState> {
@@ -92,9 +99,24 @@ class IncomingVerificationPresenter(
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
) )
} }
val currentUser by remember {
sessionStore.sessionsFlow().mapNotNull { sessions ->
sessions.find { it.userId == sessionId.value }?.let { sessionData ->
MatrixUser(
userId = sessionId,
displayName = sessionData.userDisplayName,
avatarUrl = sessionData.userAvatarUrl,
// Locally stored avatar, used as a fallback when the avatar cannot be loaded,
// for instance for a session which is not the current one
avatarThumbnail = sessionData.userAvatarData,
)
}
}
}.collectAsState(MatrixUser(sessionId))
val step by remember { val step by remember {
derivedStateOf { derivedStateOf {
stateAndDispatch.state.value.toVerificationStep( stateAndDispatch.state.value.toVerificationStep(
currentUser = currentUser,
sessionVerificationRequestDetails = verificationRequest.details, sessionVerificationRequestDetails = verificationRequest.details,
formattedSignInTime = formattedSignInTime, formattedSignInTime = formattedSignInTime,
) )
@@ -147,6 +169,7 @@ class IncomingVerificationPresenter(
} }
private fun StateMachineState?.toVerificationStep( private fun StateMachineState?.toVerificationStep(
currentUser: MatrixUser,
sessionVerificationRequestDetails: SessionVerificationRequestDetails, sessionVerificationRequestDetails: SessionVerificationRequestDetails,
formattedSignInTime: String, formattedSignInTime: String,
): Step = ): Step =
@@ -156,6 +179,7 @@ class IncomingVerificationPresenter(
StateMachineState.RejectingIncomingVerification, StateMachineState.RejectingIncomingVerification,
null -> { null -> {
Step.Initial( Step.Initial(
currentUser = currentUser,
deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName, deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
deviceId = sessionVerificationRequestDetails.deviceId, deviceId = sessionVerificationRequestDetails.deviceId,
formattedSignInTime = formattedSignInTime, formattedSignInTime = formattedSignInTime,
@@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
@@ -21,6 +22,7 @@ data class IncomingVerificationState(
@Stable @Stable
sealed interface Step { sealed interface Step {
data class Initial( data class Initial(
val currentUser: MatrixUser,
val deviceDisplayName: String?, val deviceDisplayName: String?,
val deviceId: DeviceId, val deviceId: DeviceId,
val formattedSignInTime: String, val formattedSignInTime: String,
@@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
internal fun aStepInitial( internal fun aStepInitial(
isWaiting: Boolean = false, isWaiting: Boolean = false,
) = Step.Initial( ) = Step.Initial(
currentUser = MatrixUser(
userId = UserId("@bob:example.com"),
displayName = USER_NAME_BOB,
avatarUrl = null,
),
deviceDisplayName = "Element X Android", deviceDisplayName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"), deviceId = DeviceId("ILAKNDNASDLK"),
formattedSignInTime = "12:34", formattedSignInTime = "12:34",
@@ -31,10 +31,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.R import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
@@ -42,11 +40,15 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
/** /**
@@ -167,16 +169,21 @@ private fun ContentInitial(
stepInitial: Step.Initial, stepInitial: Step.Initial,
request: VerificationRequest.Incoming, request: VerificationRequest.Incoming,
) { ) {
when (request) { Column(
is VerificationRequest.Incoming.OtherSession -> { modifier = Modifier.fillMaxWidth(),
Column( verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.fillMaxWidth(), ) {
verticalArrangement = Arrangement.spacedBy(24.dp), when (request) {
) { is VerificationRequest.Incoming.OtherSession -> {
SessionDetailsView( AccountInfoCard(
deviceName = stepInitial.deviceDisplayName, state = AccountInfoCardState(
deviceId = stepInitial.deviceId, matrixUser = stepInitial.currentUser,
signInFormattedTimestamp = stepInitial.formattedSignInTime, mode = AccountInfoCardMode.DeviceVerification(
deviceName = stepInitial.deviceDisplayName,
deviceId = stepInitial.deviceId,
signInFormattedTimestamp = stepInitial.formattedSignInTime,
),
),
) )
Text( Text(
modifier = Modifier modifier = Modifier
@@ -187,15 +194,15 @@ private fun ContentInitial(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
} is VerificationRequest.Incoming.User -> {
is VerificationRequest.Incoming.User -> { AccountInfoCard(
Column( state = AccountInfoCardState(
modifier = Modifier matrixUser = stepInitial.currentUser,
.fillMaxWidth() mode = AccountInfoCardMode.UserVerification(
.padding(top = 24.dp), otherUser = request.details.senderProfile,
) { hint = stringResource(R.string.screen_session_verification_user_requesting_verification),
VerificationUserProfileContent( ),
user = request.details.senderProfile, ),
) )
} }
} }
@@ -217,7 +224,7 @@ private fun IncomingVerificationBottomMenu(
showProgress = step.isWaiting, showProgress = step.isWaiting,
onClick = { eventSink(IncomingVerificationViewEvent.StartVerification) }, onClick = { eventSink(IncomingVerificationViewEvent.StartVerification) },
) )
TextButton( OutlinedButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ignore), text = stringResource(CommonStrings.action_ignore),
enabled = !step.isWaiting, enabled = !step.isWaiting,
@@ -262,9 +269,9 @@ private fun IncomingVerificationBottomMenu(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun IncomingVerificationViewPreview(@PreviewParameter( internal fun IncomingVerificationViewPreview(
IncomingVerificationStatePreviewParam::class @PreviewParameter(IncomingVerificationStatePreviewParam::class) state: IncomingVerificationState
) state: IncomingVerificationState) = ElementPreview { ) = ElementPreview {
IncomingVerificationView( IncomingVerificationView(
state = state, state = state,
) )
@@ -1,103 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.verifysession.impl.incoming.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.verifysession.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SessionDetailsView(
deviceName: String?,
deviceId: DeviceId,
signInFormattedTimestamp: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = ElementTheme.colors.borderDisabled,
shape = RoundedCornerShape(8.dp)
)
.padding(24.dp)
.semantics(mergeDescendants = true) {},
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RoundedIconAtom(
modifier = Modifier,
size = RoundedIconAtomSize.Big,
resourceId = CompoundDrawables.ic_compound_devices
)
Text(
text = deviceName ?: deviceId.value,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextWithLabelMolecule(
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
text = signInFormattedTimestamp,
modifier = Modifier.weight(2f),
)
TextWithLabelMolecule(
label = stringResource(CommonStrings.common_device_id),
text = deviceId.value,
modifier = Modifier.weight(5f),
spellText = true,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SessionDetailsViewPreview() = ElementPreview {
Column {
SessionDetailsView(
deviceName = "Element X Android",
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
SessionDetailsView(
deviceName = null,
deviceId = DeviceId("ILAKNDNASDLK"),
signInFormattedTimestamp = "12:34",
)
}
}
@@ -1,94 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.verifysession.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
/**
* Ref: https://www.figma.com/design/lMrKOhS8BEb75GXVq7FnNI/ER-96--User-Verification-by-Emoji?node-id=116-52049
*/
@Composable
fun VerificationUserProfileContent(
user: MatrixUser,
modifier: Modifier = Modifier,
) {
val avatarData = remember(user) {
user.getAvatarData(AvatarSize.UserVerification)
}
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = user.getBestName(),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
if (user.displayName.isNullOrEmpty().not()) {
Text(
text = user.userId.value,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun VerificationUserProfileContentPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar
) {
VerificationUserProfileContent(
user = MatrixUser(
userId = UserId("@alice:example.com"),
displayName = USER_NAME_ALICE,
avatarUrl = "https://example.com/avatar.png",
)
)
}
@@ -44,6 +44,7 @@
<string name="screen_session_verification_use_another_device_title">"Open the app on another verified device"</string> <string name="screen_session_verification_use_another_device_title">"Open the app on another verified device"</string>
<string name="screen_session_verification_user_initiator_subtitle">"For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate."</string> <string name="screen_session_verification_user_initiator_subtitle">"For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate."</string>
<string name="screen_session_verification_user_initiator_title">"Verify this user?"</string> <string name="screen_session_verification_user_initiator_title">"Verify this user?"</string>
<string name="screen_session_verification_user_requesting_verification">"User requesting verification:"</string>
<string name="screen_session_verification_user_responder_subtitle">"For extra security, another user wants to verify your digital identity. You’ll be shown a set of emojis to compare."</string> <string name="screen_session_verification_user_responder_subtitle">"For extra security, another user wants to verify your digital identity. You’ll be shown a set of emojis to compare."</string>
<string name="screen_session_verification_waiting_another_device_subtitle">"You should see a popup on the other device. Start the verification from there now."</string> <string name="screen_session_verification_waiting_another_device_subtitle">"You should see a popup on the other device. Start the verification from there now."</string>
<string name="screen_session_verification_waiting_another_device_title">"Start verification on the other device"</string> <string name="screen_session_verification_waiting_another_device_title">"Start verification on the other device"</string>
@@ -18,10 +18,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationR
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -41,6 +48,51 @@ class IncomingVerificationPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test
fun `present - the current user is built from the stored session`() = runTest {
assertCurrentUserIsBuiltFromStoredSession(anIncomingSessionVerificationRequest)
}
@Test
fun `present - incoming user verification - the current user is built from the stored session`() = runTest {
assertCurrentUserIsBuiltFromStoredSession(anIncomingUserVerificationRequest)
}
private suspend fun TestScope.assertCurrentUserIsBuiltFromStoredSession(
verificationRequest: VerificationRequest.Incoming,
) {
createPresenter(
verificationRequest = verificationRequest,
service = FakeSessionVerificationService(
acknowledgeVerificationRequestLambda = { },
resetLambda = { },
),
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_SESSION_ID.value,
userDisplayName = A_USER_NAME,
userAvatarUrl = AN_AVATAR_URL,
userAvatarData = "avatarData",
)
),
),
).test {
val step = awaitItem().step as IncomingVerificationState.Step.Initial
assertThat(step.currentUser).isEqualTo(MatrixUser(A_SESSION_ID))
val stepWithUser = awaitItem().step as IncomingVerificationState.Step.Initial
assertThat(stepWithUser.currentUser).isEqualTo(
MatrixUser(
userId = A_SESSION_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
avatarThumbnail = "avatarData",
)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - nominal case - incoming verification successful`() = runTest { fun `present - nominal case - incoming verification successful`() = runTest {
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> } val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
@@ -59,6 +111,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.step).isEqualTo( assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial( IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name", deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID, deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false", formattedSignInTime = "567 TimeOrDate false",
@@ -128,6 +181,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.step).isEqualTo( assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial( IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name", deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID, deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false", formattedSignInTime = "567 TimeOrDate false",
@@ -193,6 +247,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.step).isEqualTo( assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial( IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name", deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID, deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false", formattedSignInTime = "567 TimeOrDate false",
@@ -228,6 +283,7 @@ class IncomingVerificationPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.step).isEqualTo( assertThat(initialState.step).isEqualTo(
IncomingVerificationState.Step.Initial( IncomingVerificationState.Step.Initial(
currentUser = MatrixUser(A_SESSION_ID),
deviceDisplayName = "a device name", deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID, deviceId = A_DEVICE_ID,
formattedSignInTime = "567 TimeOrDate false", formattedSignInTime = "567 TimeOrDate false",
@@ -310,15 +366,32 @@ private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.
) )
) )
private val anIncomingUserVerificationRequest = VerificationRequest.Incoming.User(
details = SessionVerificationRequestDetails(
senderProfile = MatrixUser(
userId = A_USER_ID_2,
displayName = "another user name",
avatarUrl = null,
),
flowId = FlowId("flowId"),
deviceId = A_DEVICE_ID,
deviceDisplayName = "a device name",
firstSeenTimestamp = A_TIMESTAMP,
)
)
internal fun TestScope.createPresenter( internal fun TestScope.createPresenter(
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest, verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(), service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: DateFormatter = FakeDateFormatter(), dateFormatter: DateFormatter = FakeDateFormatter(),
sessionStore: SessionStore = InMemorySessionStore(),
) = IncomingVerificationPresenter( ) = IncomingVerificationPresenter(
verificationRequest = verificationRequest, verificationRequest = verificationRequest,
navigator = navigator, navigator = navigator,
sessionVerificationService = service, sessionVerificationService = service,
sessionId = A_SESSION_ID,
sessionStore = sessionStore,
stateMachine = IncomingVerificationStateMachine(service), stateMachine = IncomingVerificationStateMachine(service),
dateFormatter = dateFormatter, dateFormatter = dateFormatter,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
@@ -15,10 +15,16 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.features.viewfolder.impl.file.ColorationMode import io.element.android.features.viewfolder.impl.file.ColorationMode
import io.element.android.features.viewfolder.impl.file.FileContent import io.element.android.features.viewfolder.impl.file.FileContent
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultTextFileViewer : TextFileViewer { class DefaultTextFileViewer(
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : TextFileViewer {
@Composable @Composable
override fun Render( override fun Render(
lines: ImmutableList<String>, lines: ImmutableList<String>,
@@ -27,6 +33,11 @@ class DefaultTextFileViewer : TextFileViewer {
FileContent( FileContent(
lines = lines, lines = lines,
colorationMode = ColorationMode.None, colorationMode = ColorationMode.None,
onLineClick = { line ->
clipboardHelper.copyPlainText(line) {
toastHelper.show(CommonStrings.common_line_copied_to_clipboard)
}
},
modifier = modifier modifier = modifier
) )
} }
@@ -23,12 +23,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -37,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
internal fun FileContent( internal fun FileContent(
lines: ImmutableList<String>, lines: ImmutableList<String>,
colorationMode: ColorationMode, colorationMode: ColorationMode,
onLineClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@@ -60,6 +59,7 @@ internal fun FileContent(
lineNumber = index + 1, lineNumber = index + 1,
line = line, line = line,
colorationMode = colorationMode, colorationMode = colorationMode,
onClick = { onLineClick(line) },
) )
} }
} }
@@ -71,18 +71,12 @@ private fun LineRow(
lineNumber: Int, lineNumber: Int,
line: String, line: String,
colorationMode: ColorationMode, colorationMode: ColorationMode,
onClick: () -> Unit,
) { ) {
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = { .clickable(onClick = onClick)
context.copyToClipboard(
text = line,
toastMessage = toastMessage,
)
})
) { ) {
Text( Text(
modifier = Modifier modifier = Modifier
@@ -11,4 +11,5 @@ package io.element.android.features.viewfolder.impl.file
sealed interface ViewFileEvent { sealed interface ViewFileEvent {
data object SaveOnDisk : ViewFileEvent data object SaveOnDisk : ViewFileEvent
data object Share : ViewFileEvent data object Share : ViewFileEvent
data class CopyToClipboard(val text: String) : ViewFileEvent
} }
@@ -18,8 +18,11 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -30,6 +33,8 @@ class ViewFilePresenter(
private val fileContentReader: FileContentReader, private val fileContentReader: FileContentReader,
private val fileShare: FileShare, private val fileShare: FileShare,
private val fileSave: FileSave, private val fileSave: FileSave,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<ViewFileState> { ) : Presenter<ViewFileState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -48,6 +53,9 @@ class ViewFilePresenter(
when (event) { when (event) {
ViewFileEvent.Share -> coroutineScope.share(path) ViewFileEvent.Share -> coroutineScope.share(path)
ViewFileEvent.SaveOnDisk -> coroutineScope.save(path) ViewFileEvent.SaveOnDisk -> coroutineScope.save(path)
is ViewFileEvent.CopyToClipboard -> clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_line_copied_to_clipboard)
}
} }
} }
@@ -80,6 +80,7 @@ fun ViewFileView(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
lines = state.lines.data.toImmutableList(), lines = state.lines.data.toImmutableList(),
colorationMode = state.colorationMode, colorationMode = state.colorationMode,
onLineClick = { state.eventSink(ViewFileEvent.CopyToClipboard(it)) },
) )
is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null) is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null)
} }
@@ -18,8 +18,13 @@ import io.element.android.features.viewfolder.impl.file.FileSave
import io.element.android.features.viewfolder.impl.file.FileShare import io.element.android.features.viewfolder.impl.file.FileShare
import io.element.android.features.viewfolder.impl.file.ViewFileEvent import io.element.android.features.viewfolder.impl.file.ViewFileEvent
import io.element.android.features.viewfolder.impl.file.ViewFilePresenter import io.element.android.features.viewfolder.impl.file.ViewFilePresenter
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@@ -126,17 +131,51 @@ class ViewFilePresenterTest {
} }
} }
@Test
fun `present - copy line on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_line_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy line on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private fun createPresenter( private fun createPresenter(
path: String = "aPath", path: String = "aPath",
name: String = "aName", name: String = "aName",
fileContentReader: FileContentReader = FakeFileContentReader(), fileContentReader: FileContentReader = FakeFileContentReader(),
fileShare: FileShare = FakeFileShare(), fileShare: FileShare = FakeFileShare(),
fileSave: FileSave = FakeFileSave(), fileSave: FileSave = FakeFileSave(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
) = ViewFilePresenter( ) = ViewFilePresenter(
path = path, path = path,
name = name, name = name,
fileContentReader = fileContentReader, fileContentReader = fileContentReader,
fileShare = fileShare, fileShare = fileShare,
fileSave = fileSave, fileSave = fileSave,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
+6
View File
@@ -65,3 +65,9 @@ android.r8.gradual.support=true
# Enabled parallel sync for Gradle 9.4+ # Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true org.gradle.tooling.parallel=true
# When re-recording, keep the existing screenshot if it differs by less than `maxPercentDifference`
# (see PaparazziPreviewRule in tests/uitests/.../base/ScreenshotTest.kt). Without this, recording has
# no tolerance at all while verification has one, so renderer noise far below the threshold that
# `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot.
app.cash.paparazzi.overwriteOnMaxPercentDifference=true
+8 -8
View File
@@ -5,9 +5,9 @@
# Project # Project
android_gradle_plugin = "9.3.2" android_gradle_plugin = "9.3.2"
# When updating this, please also update the version in the file ./idea/kotlinc.xml # When updating this, please also update the version in the file ./idea/kotlinc.xml
kotlin = "2.4.10" kotlin = "2.4.20"
kotlinpoet = "2.4.0" kotlinpoet = "2.4.0"
ksp = "2.3.11" ksp = "2.3.12"
firebaseAppDistribution = "5.3.0" firebaseAppDistribution = "5.3.0"
# AndroidX # AndroidX
@@ -19,7 +19,7 @@ lifecycle = "2.11.0"
activity = "1.13.0" activity = "1.13.0"
media3 = "1.11.1" media3 = "1.11.1"
camera = "1.6.2" camera = "1.6.2"
work = "2.11.2" work = "2.12.0"
# Compose # Compose
compose_bom = "2026.09.00" compose_bom = "2026.09.00"
@@ -51,7 +51,7 @@ maplibre-compose = "0.17.0"
dependencyAnalysis = "3.19.2" dependencyAnalysis = "3.19.2"
# DI # DI
metro = "1.4.4" metro = "1.4.5"
# Auto service # Auto service
autoservice = "1.1.1" autoservice = "1.1.1"
@@ -93,7 +93,7 @@ jna = "net.java.dev.jna:jna:5.19.1"
# AndroidX # AndroidX
androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.10.0" androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.11.0"
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.4.2" androidx_exifinterface = "androidx.exifinterface:exifinterface:1.4.2"
@@ -102,7 +102,7 @@ androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constr
androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }
androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" }
androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.1.0" androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.1.1"
androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
@@ -171,8 +171,8 @@ test_mockk = "io.mockk:mockk:1.14.11"
test_konsist = "com.github.jmartinesp:konsist:0.18.0" test_konsist = "com.github.jmartinesp:konsist:0.18.0"
test_turbine = "app.cash.turbine:turbine:1.2.1" test_turbine = "app.cash.turbine:turbine:1.2.1"
test_truth = "com.google.truth:truth:1.4.5" test_truth = "com.google.truth:truth:1.4.5"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.23"
test_robolectric = "org.robolectric:robolectric:4.16.1" test_robolectric = "org.robolectric:robolectric:4.17"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3" test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3"
test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" }
@@ -9,8 +9,11 @@
package io.element.android.libraries.androidutils.clipboard package io.element.android.libraries.androidutils.clipboard
import android.content.ClipData import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
@@ -24,7 +27,30 @@ class AndroidClipboardHelper(
) : ClipboardHelper { ) : ClipboardHelper {
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>()) private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())
override fun copyPlainText(text: String) { override fun copyPlainText(
clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) text: String,
isSensitive: Boolean,
onSuccessOnOldDevice: () -> Unit,
) {
val clipData = ClipData.newPlainText("", text)
if (isSensitive) {
clipData.description.extras = PersistableBundle().apply {
putBoolean(EXTRA_IS_SENSITIVE, true)
}
}
clipboardManager.setPrimaryClip(clipData)
// On Android 13+, the system already displays a confirmation when content is copied to the clipboard.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
onSuccessOnOldDevice()
}
}
private companion object {
// The constant only exists from API 33, but the documentation recommends using its value on older versions too.
val EXTRA_IS_SENSITIVE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ClipDescription.EXTRA_IS_SENSITIVE
} else {
"android.content.extra.IS_SENSITIVE"
}
} }
} }
@@ -8,9 +8,18 @@
package io.element.android.libraries.androidutils.clipboard package io.element.android.libraries.androidutils.clipboard
/**
* Wrapper class for handling clipboard operations so it can be used in JVM environments.
*/
interface ClipboardHelper { interface ClipboardHelper {
fun copyPlainText(text: String) /**
* Copy a text to the clipboard.
*
* @param text the text to copy
* @param isSensitive if true, the clip is flagged so the system clipboard preview (Android 13+) and keyboards hide its content.
* @param onSuccessOnOldDevice invoked after the copy only on Android versions older than 13, since newer versions already
* display a confirmation. Can be used to render a Toast or a Snackbar.
*/
fun copyPlainText(
text: String,
isSensitive: Boolean = false,
onSuccessOnOldDevice: () -> Unit,
)
} }
@@ -8,10 +8,21 @@
package io.element.android.libraries.androidutils.clipboard package io.element.android.libraries.androidutils.clipboard
class FakeClipboardHelper : ClipboardHelper { class FakeClipboardHelper(
private val isOldDevice: Boolean = true,
) : ClipboardHelper {
var clipboardContents: Any? = null var clipboardContents: Any? = null
var isSensitive: Boolean? = null
override fun copyPlainText(text: String) { override fun copyPlainText(
text: String,
isSensitive: Boolean,
onSuccessOnOldDevice: () -> Unit,
) {
clipboardContents = text clipboardContents = text
this.isSensitive = isSensitive
if (isOldDevice) {
onSuccessOnOldDevice()
}
} }
} }
@@ -1,23 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.system
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
class CopyToClipboardUseCase(
private val context: Context,
) {
fun execute(text: CharSequence) {
context.getSystemService<ClipboardManager>()
?.setPrimaryClip(ClipData.newPlainText("", text))
}
}
@@ -50,25 +50,6 @@ fun Context.getVersionCodeFromManifest(): Long {
) )
} }
// ==============================================================================================================
// Clipboard helper
// ==============================================================================================================
/**
* Copy a text to the clipboard, and display a Toast when done.
*
* @receiver the context
* @param text the text to copy
* @param toastMessage content of the toast message as a String resource. Null for no toast
*/
fun Context.copyToClipboard(
text: CharSequence,
toastMessage: String? = null
) {
CopyToClipboardUseCase(this).execute(text)
toastMessage?.let { toast(it) }
}
/** /**
* Shows notification settings for the current app. * Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings * In android O will directly opens the notification settings, in lower version it will show the App settings
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.di.annotations.ApplicationContext
@ContributesBinding(AppScope::class)
class AndroidToastHelper(
@ApplicationContext private val context: Context,
) : ToastHelper {
override fun show(resId: Int) {
context.toast(resId)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
class FakeToastHelper : ToastHelper {
val shownToasts = mutableListOf<Int>()
override fun show(resId: Int) {
shownToasts.add(resId)
}
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
import androidx.annotation.StringRes
interface ToastHelper {
fun show(@StringRes resId: Int)
}

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