mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -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(
|
||||||
|
|||||||
+81
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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
|
||||||
|
|||||||
+191
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule enterprise updated: bcffe42d5c...073abe3b44
+61
-83
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-21
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-14
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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(),
|
||||||
|
|||||||
+2
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-9
@@ -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,
|
||||||
|
|||||||
+31
-8
@@ -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?,
|
||||||
|
|||||||
+9
-9
@@ -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,
|
||||||
|
|||||||
+63
-20
@@ -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),
|
||||||
|
|||||||
+11
@@ -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
|
||||||
|
|||||||
+9
-3
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+78
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-4
@@ -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,
|
||||||
|
|||||||
+69
-4
@@ -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()
|
||||||
|
|||||||
+40
-3
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
+25
@@ -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()
|
||||||
|
|||||||
+4
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -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
|
||||||
|
|||||||
+1
@@ -267,6 +267,7 @@ private fun ThreadListItemRow(
|
|||||||
UnreadIndicatorAtom(
|
UnreadIndicatorAtom(
|
||||||
size = 14.dp,
|
size = 14.dp,
|
||||||
isVisible = hasUnreadNotifications,
|
isVisible = hasUnreadNotifications,
|
||||||
|
count = 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
+9
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-13
@@ -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 = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+41
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -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 {
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-12
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-16
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -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 {
|
||||||
|
|||||||
+3
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
@@ -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>
|
||||||
|
|||||||
+41
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -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 {
|
||||||
|
|||||||
+1
@@ -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
|
||||||
|
|||||||
+10
-2
@@ -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()
|
||||||
|
|||||||
+3
-10
@@ -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 = {
|
||||||
|
|||||||
+3
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -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(
|
||||||
|
|||||||
+31
-3
@@ -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)
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -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)
|
||||||
|
|||||||
+1
-8
@@ -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) {
|
||||||
|
|||||||
+4
@@ -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(),
|
||||||
|
|||||||
+59
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -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,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+6
@@ -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",
|
||||||
|
|||||||
+32
-25
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
-103
@@ -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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-94
@@ -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>
|
||||||
|
|||||||
+73
@@ -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,
|
||||||
|
|||||||
+12
-1
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-10
@@ -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
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
+28
-2
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-23
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-19
@@ -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
|
||||||
|
|||||||
+23
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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
Reference in New Issue
Block a user