Merge branch 'develop' into feature/fga/join_space

This commit is contained in:
ganfra 2025-09-19 16:35:55 +02:00
commit c4308e9810
446 changed files with 5669 additions and 2617 deletions

1
.gitignore vendored
View file

@ -62,6 +62,7 @@ captures/
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/copilot
.idea/copilot.*
.idea/inspectionProfiles
# Shelved changes in the IDE
.idea/shelf

View file

@ -24,6 +24,7 @@ import extension.koverDependencies
import extension.locales
import extension.setupDependencyInjection
import extension.setupKover
import extension.testCommonDependencies
import java.util.Locale
plugins {
@ -290,15 +291,9 @@ dependencies {
implementation(libs.matrix.emojibase.bindings)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
koverDependencies()
}

View file

@ -9,6 +9,7 @@
import extension.allFeaturesApi
import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
@ -42,18 +43,12 @@ dependencies {
implementation(projects.features.ftue.api)
implementation(projects.features.share.api)
implementation(projects.features.viewfolder.api)
implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test)
@ -61,11 +56,8 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
testImplementation(libs.test.appyx.junit)
testImplementation(libs.test.arch.core)
}

View file

@ -52,7 +52,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
@ -119,7 +118,6 @@ class LoggedInFlowNode(
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
@ -277,9 +275,6 @@ class LoggedInFlowNode(
@Parcelize
data class IncomingShare(val intent: Intent) : NavTarget
@Parcelize
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
@Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
}
@ -324,10 +319,6 @@ class LoggedInFlowNode(
override fun onReportBugClick() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
override fun onLogoutForNativeSlidingSyncMigrationNeeded() {
backstack.push(NavTarget.LogoutForNativeSlidingSyncMigrationNeeded)
}
}
homeEntryPoint
.nodeBuilder(this, buildContext)
@ -454,8 +445,7 @@ class LoggedInFlowNode(
.build()
}
NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext)
.build()
ftueEntryPoint.createNode(this, buildContext)
}
NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
@ -486,17 +476,6 @@ class LoggedInFlowNode(
.params(ShareEntryPoint.Params(intent = navTarget.intent))
.build()
}
is NavTarget.LogoutForNativeSlidingSyncMigrationNeeded -> {
val callback = object : LogoutEntryPoint.Callback {
override fun onChangeRecoveryKeyClick() {
backstack.push(NavTarget.SecureBackup())
}
}
logoutEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(IncomingVerificationEntryPoint.Params(navTarget.data))

View file

@ -39,7 +39,6 @@ import io.element.android.features.login.api.accesscontrol.AccountProviderAccess
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
@ -47,13 +46,13 @@ import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -65,13 +64,12 @@ import timber.log.Timber
class RootFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService,
private val sessionStore: SessionStore,
private val accountProviderAccessControl: AccountProviderAccessControl,
private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
@ -154,7 +152,7 @@ class RootFlowNode(
onSuccess: (SessionId) -> Unit,
onFailure: () -> Unit
) {
val latestSessionId = authenticationService.getLatestSessionId()
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
onFailure()
return
@ -200,11 +198,6 @@ class RootFlowNode(
@Parcelize
data object BugReport : NavTarget
@Parcelize
data class ViewLogs(
val rootPath: String,
) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -244,31 +237,12 @@ class RootFlowNode(
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback {
override fun onBugReportSent() {
backstack.pop()
}
override fun onViewLogs(basePath: String) {
backstack.push(NavTarget.ViewLogs(rootPath = basePath))
}
}
bugReportEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.ViewLogs -> {
val callback = object : ViewFolderEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
val params = ViewFolderEntryPoint.Params(
rootPath = navTarget.rootPath,
)
viewFolderEntryPoint
bugReportEntryPoint
.nodeBuilder(this, buildContext)
.params(params)
.callback(callback)
.build()
}
@ -294,7 +268,7 @@ class RootFlowNode(
private suspend fun onLoginLink(params: LoginParams) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
@ -311,7 +285,7 @@ class RootFlowNode(
private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow(null)
@ -368,3 +342,5 @@ class RootFlowNode(
.attachSession()
}
}
private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId)

View file

@ -9,6 +9,6 @@ package io.element.android.appnav.di
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface RoomComponentFactory {
fun interface RoomComponentFactory {
fun create(room: JoinedRoom): Any
}

View file

@ -12,9 +12,9 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
@ -28,7 +28,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
*/
@Inject
class RootNavStateFlowFactory(
private val authenticationService: MatrixAuthenticationService,
private val sessionStore: SessionStore,
private val cacheService: CacheService,
private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder,
@ -39,7 +39,7 @@ class RootNavStateFlowFactory(
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine(
cacheIndexFlow(savedStateMap),
authenticationService.loggedInStateFlow(),
sessionStore.loggedInStateFlow(),
) { cacheIndex, loggedInState ->
RootNavState(
cacheIndex = cacheIndex,

View file

@ -2,5 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Sair &amp; Atualizar"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Seu homeserver não suporta mais o protocolo antigo. Termine sessão e volte a iniciar sessão para continuar a utilizar a aplicação."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"O teu servidor já não permite o protocolo antigo. Termine sessão e volte a iniciá-la para continuar a utilizar a aplicação."</string>
</resources>

View file

@ -501,22 +501,16 @@ class LoggedInPresenterTest {
@Test
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
// The migration will be forced if:
// - The user is not using the native sliding sync
// - The sliding sync proxy is no longer supported
// - The native sliding sync is supported
// The migration will be forced if the user is not using the native sliding sync
val matrixClient = FakeMatrixClient(
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
availableSlidingSyncVersionsLambda = { Result.success(listOf(SlidingSyncVersion.Native)) },
)
createLoggedInPresenter(
matrixClient = matrixClient,
).test {
val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
}
}

View file

@ -9,4 +9,4 @@ package io.element.android.features.analytics.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface AnalyticsEntryPoint : SimpleFeatureEntryPoint
fun interface AnalyticsEntryPoint : SimpleFeatureEntryPoint

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -30,13 +31,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.browser)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
}

View file

@ -0,0 +1,39 @@
/*
* 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.analytics.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultAnalyticsEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node creation`() {
val entryPoint = DefaultAnalyticsEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
AnalyticsOptInNode(
buildContext = buildContext,
plugins = plugins,
AnalyticsOptInPresenter(
buildMeta = aBuildMeta(),
analyticsService = FakeAnalyticsService()
)
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(AnalyticsOptInNode::class.java)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -22,8 +23,5 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(projects.tests.testutils)
testCommonDependencies(libs)
}

View file

@ -29,6 +29,7 @@ interface ElementCallEntryPoint {
* @param senderName The name of the sender of the event that started the call.
* @param avatarUrl The avatar url of the room or DM.
* @param timestamp The timestamp of the event that started the call.
* @param expirationTimestamp The timestamp at which the call should stop ringing.
* @param notificationChannelId The id of the notification channel to use for the call notification.
* @param textContent The text content of the notification. If null the default content from the system will be used.
*/
@ -40,6 +41,7 @@ interface ElementCallEntryPoint {
senderName: String?,
avatarUrl: String?,
timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String,
textContent: String?,
)

View file

@ -1,6 +1,7 @@
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2024 New Vector Ltd.
@ -87,12 +88,7 @@ dependencies {
implementation(libs.element.call.embedded)
api(projects.features.call.api)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk)
testCommonDependencies(libs, true)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
@ -100,7 +96,5 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(projects.services.toolbox.test)
}

View file

@ -43,6 +43,7 @@ class DefaultElementCallEntryPoint(
senderName: String?,
avatarUrl: String?,
timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String,
textContent: String?,
) {
@ -55,6 +56,7 @@ class DefaultElementCallEntryPoint(
senderName = senderName,
avatarUrl = avatarUrl,
timestamp = timestamp,
expirationTimestamp = expirationTimestamp,
notificationChannelId = notificationChannelId,
textContent = textContent,
)

View file

@ -26,4 +26,6 @@ data class CallNotificationData(
val notificationChannelId: String,
val timestamp: Long,
val textContent: String?,
// Expiration timestamp in millis since epoch
val expirationTimestamp: Long,
) : Parcelable

View file

@ -64,6 +64,7 @@ class RingingCallNotificationCreator(
roomAvatarUrl: String?,
notificationChannelId: String,
timestamp: Long,
expirationTimestamp: Long,
textContent: String?,
): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
@ -88,6 +89,7 @@ class RingingCallNotificationCreator(
notificationChannelId = notificationChannelId,
timestamp = timestamp,
textContent = textContent,
expirationTimestamp = expirationTimestamp,
)
val declineIntent = PendingIntentCompat.getBroadcast(

View file

@ -176,6 +176,7 @@ internal fun IncomingCallScreenPreview() = ElementPreview {
notificationChannelId = "incoming_call",
timestamp = 0L,
textContent = null,
expirationTimestamp = 1000L,
),
onAnswer = {},
onCancel = {},

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@ -53,7 +54,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import kotlin.math.min
/**
* Manages the active call state.
@ -98,6 +99,7 @@ class DefaultActiveCallManager(
private val defaultCurrentCallService: DefaultCurrentCallService,
private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock,
) : ActiveCallManager {
private val tag = "DefaultActiveCallManager"
private var timedOutCallJob: Job? = null
@ -118,8 +120,20 @@ class DefaultActiveCallManager(
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
mutex.withLock {
val ringDuration =
min(
notificationData.expirationTimestamp - systemClock.epochMillis(),
ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L
)
if (ringDuration < 0) {
// Should already have stopped ringing, ignore.
Timber.tag(tag).d("Received timed-out incoming ringing call for room id: ${notificationData.roomId}, cancel ringing")
return
}
appForegroundStateService.updateHasRingingCall(true)
Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}")
Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}, ringDuration(ms): $ringDuration")
if (activeCall.value != null) {
displayMissedCallNotification(notificationData)
Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData")
@ -138,14 +152,14 @@ class DefaultActiveCallManager(
showIncomingCallNotification(notificationData)
// Wait for the ringing call to time out
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
delay(timeMillis = ringDuration)
incomingCallTimedOut(displayMissedCallNotification = true)
}
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
if (activeWakeLock?.isHeld == false) {
Timber.tag(tag).d("Acquiring partial wakelock")
activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
activeWakeLock.acquire(ringDuration)
}
}
}
@ -180,12 +194,22 @@ class DefaultActiveCallManager(
}
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
if (activeCall.value?.callType != callType) {
Timber.tag(tag).d("Hung up call: $callType")
val currentActiveCall = activeCall.value ?: run {
Timber.tag(tag).w("No active call, ignoring hang up")
return
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
return
}
Timber.tag(tag).d("Hung up call: $callType")
if (currentActiveCall.callState is CallState.Ringing) {
// Decline the call
val notificationData = currentActiveCall.callState.notificationData
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
?.getRoom(notificationData.roomId)
?.declineCall(notificationData.eventId)
}
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
@ -226,6 +250,7 @@ class DefaultActiveCallManager(
notificationChannelId = notificationData.notificationChannelId,
timestamp = notificationData.timestamp,
textContent = notificationData.textContent,
expirationTimestamp = notificationData.expirationTimestamp,
) ?: return
runCatchingExceptions {
notificationManagerCompat.notify(
@ -256,6 +281,43 @@ class DefaultActiveCallManager(
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeRingingCall() {
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
val ringingInfo = activeCall.callState as CallState.Ringing
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
val room = client.getRoom(callType.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
// If we have declined from another phone we want to stop ringing.
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
.filter { decliner ->
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
// only want to listen if the call was declined from another of my sessions,
// (we are ringing for an incoming call in a DM)
decliner == client.sessionId
}
}
.onEach { decliner ->
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
// Remove the active call and cancel the notification
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
activeWakeLock.release()
}
cancelIncomingCallNotification()
}
.launchIn(coroutineScope)
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
// has joined the call from another session.
activeCall

View file

@ -45,8 +45,14 @@ class DefaultCallWidgetProvider(
val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull()
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted, direct = room.isDm())
val roomInfo = room.info()
val isEncrypted = roomInfo.isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(
baseUrl = baseUrl,
encrypted = isEncrypted,
direct = room.isDm(),
hasActiveCall = roomInfo.hasRoomCall,
)
val callUrl = room.generateWidgetWebViewUrl(
widgetSettings = widgetSettings,
clientId = clientId,

View file

@ -59,6 +59,7 @@ class DefaultElementCallEntryPointTest {
senderName = "senderName",
avatarUrl = "avatarUrl",
timestamp = 0,
expirationTimestamp = 0,
notificationChannelId = "notificationChannelId",
textContent = "textContent",
)

View file

@ -73,6 +73,7 @@ class RingingCallNotificationCreatorTest {
roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId",
timestamp = 0L,
expirationTimestamp = 20L,
textContent = "textContent",
)

View file

@ -22,13 +22,16 @@ import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@ -36,8 +39,12 @@ import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolde
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.plantTestTimber
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -164,6 +171,102 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = mockk<JoinedRoom>(relaxed = true)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `Decline event - Declining from another session should stop ringing`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = FakeJoinedRoom()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
runCurrent()
// Simulate declined from other session
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
runCurrent()
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `Decline event - Should ignore decline for other notification events`() = runTest {
plantTestTimber()
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val room = FakeJoinedRoom()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
val manager = createActiveCallManager(
matrixClientProvider = clientProvider,
notificationManagerCompat = notificationManagerCompat
)
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
runCurrent()
// Simulate declined for another notification event
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
runCurrent()
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
@ -267,6 +370,83 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `IncomingCall - rings no longer than expiration time`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val clock = FakeSystemClock()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val eventTimestamp = A_FAKE_TIMESTAMP
// The call should not ring more than 30 seconds after the initial event was sent
val expirationTimestamp = eventTimestamp + 30_000
val callNotificationData = aCallNotificationData(
timestamp = eventTimestamp,
expirationTimestamp = expirationTimestamp,
)
// suppose it took 10s to be notified
clock.epochMillisResult = eventTimestamp + 10_000
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
callType = CallType.RoomCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
),
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
// advance by 21s it should have stopped ringing
advanceTimeBy(21_000)
runCurrent()
verify { notificationManagerCompat.cancel(any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `IncomingCall - ignore expired ring lifetime`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val clock = FakeSystemClock()
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val eventTimestamp = A_FAKE_TIMESTAMP
// The call should not ring more than 30 seconds after the initial event was sent
val expirationTimestamp = eventTimestamp + 30_000
val callNotificationData = aCallNotificationData(
timestamp = eventTimestamp,
expirationTimestamp = expirationTimestamp,
)
// suppose it took 35s to be notified
clock.epochMillisResult = eventTimestamp + 35_000
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isNull()
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify(exactly = 0) { notificationManagerCompat.notify(notificationId, any()) }
}
private fun setupShadowPowerManager() {
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
@ -277,6 +457,7 @@ class DefaultActiveCallManagerTest {
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
systemClock: FakeSystemClock = FakeSystemClock(),
) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = backgroundScope,
@ -292,5 +473,6 @@ class DefaultActiveCallManagerTest {
defaultCurrentCallService = DefaultCurrentCallService(),
appForegroundStateService = FakeAppForegroundStateService(),
imageLoaderHolder = FakeImageLoaderHolder(),
systemClock = systemClock,
)
}

View file

@ -30,6 +30,7 @@ fun aCallNotificationData(
avatarUrl: String? = AN_AVATAR_URL,
notificationChannelId: String = "channel_id",
timestamp: Long = 0L,
expirationTimestamp: Long = 30_000L,
textContent: String? = null,
): CallNotificationData = CallNotificationData(
sessionId = sessionId,
@ -41,5 +42,6 @@ fun aCallNotificationData(
avatarUrl = avatarUrl,
notificationChannelId = notificationChannelId,
timestamp = timestamp,
expirationTimestamp = expirationTimestamp,
textContent = textContent,
)

View file

@ -38,6 +38,7 @@ class FakeElementCallEntryPoint(
senderName: String?,
avatarUrl: String?,
timestamp: Long,
expirationTimestamp: Long,
notificationChannelId: String,
textContent: String?,
) {

View file

@ -14,7 +14,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
fun builder(parentNode: Node, buildContext: BuildContext): Builder
interface Builder {

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2025 New Vector Ltd.
@ -37,15 +38,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
testCommonDependencies(libs, true)
testImplementation(projects.services.analytics.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -37,16 +37,7 @@ class ChangeRolesNode(
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
}
create(role)
}
private val presenter = presenterFactory.create(inputs.listType.toRoomMemberRole())
private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForRoleChanged() {
@ -63,3 +54,9 @@ class ChangeRolesNode(
)
}
}
internal fun ChangeRoomMemberRolesListType.toRoomMemberRole() = when (this) {
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
}

View file

@ -55,7 +55,7 @@ class ChangeRolesPresenter(
private val analyticsService: AnalyticsService,
) : Presenter<ChangeRolesState> {
@AssistedFactory
interface Factory {
fun interface Factory {
fun create(role: RoomMember.Role): ChangeRolesPresenter
}

View file

@ -39,16 +39,13 @@ class ChangeRoomMemberRolesRootNode(
roomComponentFactory: RoomComponentFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget.Root),
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
}
@Parcelize object NavTarget : Parcelable
data class Inputs(
val joinedRoom: JoinedRoom,
@ -60,14 +57,10 @@ class ChangeRoomMemberRolesRootNode(
override val graph = roomComponentFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
)
}
}
return createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
)
}
@Composable

View file

@ -0,0 +1,25 @@
/*
* 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.changeroommemberroles.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.api.room.RoomMember
import org.junit.Test
class ChangeRolesNodeTest {
@Test
fun `test toRoomMemberRole`() {
assertThat(ChangeRoomMemberRolesListType.Admins.toRoomMemberRole())
.isEqualTo(RoomMember.Role.Admin)
assertThat(ChangeRoomMemberRolesListType.Moderators.toRoomMemberRole())
.isEqualTo(RoomMember.Role.Moderator)
assertThat(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving.toRoomMemberRole())
.isEqualTo(RoomMember.Role.Owner(false))
}
}

View file

@ -37,7 +37,6 @@ import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.plus
class ChangeRolesPresenterTest {
@Test
@ -556,18 +555,18 @@ class ChangeRolesPresenterTest {
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
)
}
private fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): ChangeRolesPresenter {
return ChangeRolesPresenter(
role = role,
room = room,
dispatchers = dispatchers,
analyticsService = analyticsService,
)
}
}
internal fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): ChangeRolesPresenter {
return ChangeRolesPresenter(
role = role,
room = room,
dispatchers = dispatchers,
analyticsService = analyticsService,
)
}

View file

@ -0,0 +1,44 @@
/*
* 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.changeroommemberroles.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultChangeRoomMemberRolesEntyPointTest {
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultChangeRoomMemberRolesEntyPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ChangeRoomMemberRolesRootNode(
buildContext = buildContext,
plugins = plugins,
roomComponentFactory = { },
)
}
val room = FakeJoinedRoom()
val listType = ChangeRoomMemberRolesListType.Admins
val result = entryPoint.builder(parentNode, BuildContext.root(null))
.room(FakeJoinedRoom())
.listType(listType)
.build()
assertThat(result).isInstanceOf(ChangeRoomMemberRolesRootNode::class.java)
// Search for the Inputs plugin
val input = result.plugins.filterIsInstance<ChangeRoomMemberRolesRootNode.Inputs>().single()
assertThat(input.joinedRoom.roomId).isEqualTo(room.roomId)
assertThat(input.listType).isEqualTo(listType)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -43,13 +44,7 @@ dependencies {
implementation(projects.features.invitepeople.api)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs, true)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test)
@ -58,7 +53,4 @@ dependencies {
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -0,0 +1,46 @@
/*
* 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.createroom.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultCreateRoomEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultCreateRoomEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
CreateRoomFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result.plugins).contains(callback)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2024 New Vector Ltd.
@ -34,14 +35,6 @@ dependencies {
implementation(projects.libraries.uiStrings)
api(projects.features.deactivation.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirme que pretende desativar a sua conta. Esta ação não pode ser desfeita."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirma que pretendes desativar a tua conta. Esta ação não pode ser desfeita."</string>
<string name="screen_deactivate_account_delete_all_messages">"Eliminar todas as minhas mensagens"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Aviso: futuros usuários podem ver conversas incompletas."</string>
<string name="screen_deactivate_account_description">"A desativação da sua conta é %1$s, irá:"</string>

View file

@ -148,10 +148,10 @@ class AccountDeactivationPresenterTest {
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
) = AccountDeactivationPresenter(
matrixClient = matrixClient,
)
}
internal fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
) = AccountDeactivationPresenter(
matrixClient = matrixClient,
)

View file

@ -0,0 +1,34 @@
/*
* 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.logout.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultAccountDeactivationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultAccountDeactivationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
AccountDeactivationNode(
buildContext = buildContext,
plugins = plugins,
presenter = createPresenter(),
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(AccountDeactivationNode::class.java)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2024 New Vector Ltd.
@ -22,8 +23,6 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -7,14 +7,6 @@
package io.element.android.features.ftue.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface FtueEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun build(): Node
}
}
interface FtueEntryPoint : SimpleFeatureEntryPoint

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -46,14 +47,7 @@ dependencies {
implementation(projects.services.toolbox.api)
implementation(projects.appconfig)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analytics.noop)
@ -61,5 +55,4 @@ dependencies {
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
}

View file

@ -9,7 +9,6 @@ package io.element.android.features.ftue.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
@ -19,13 +18,7 @@ import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@Inject
class DefaultFtueEntryPoint : FtueEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : FtueEntryPoint.NodeBuilder {
override fun build(): Node {
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
}
}
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<FtueFlowNode>(buildContext)
}
}

View file

@ -0,0 +1,59 @@
/*
* 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.ftue.impl
import android.content.Context
import android.content.Intent
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultFtueEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultFtueEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
FtueFlowNode(
buildContext = buildContext,
plugins = plugins,
analyticsEntryPoint = { _, _ -> lambdaError() },
ftueState = createDefaultFtueService(),
analyticsService = FakeAnalyticsService(),
lockScreenEntryPoint = object : LockScreenEntryPoint {
override fun nodeBuilder(
parentNode: com.bumble.appyx.core.node.Node,
buildContext: BuildContext,
navTarget: LockScreenEntryPoint.Target
): LockScreenEntryPoint.NodeBuilder {
lambdaError()
}
override fun pinUnlockIntent(context: Context): Intent {
lambdaError()
}
},
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(FtueFlowNode::class.java)
}
}

View file

@ -226,22 +226,22 @@ class DefaultFtueServiceTest {
resetPermissionLambda.assertions().isCalledOnce()
.with(value("android.permission.POST_NOTIFICATIONS"))
}
private fun TestScope.createDefaultFtueService(
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
sessionPreferencesStore = sessionPreferencesStore,
)
}
internal fun TestScope.createDefaultFtueService(
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
sessionCoroutineScope = backgroundScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
sessionPreferencesStore = sessionPreferencesStore,
)

View file

@ -28,6 +28,5 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun onSessionConfirmRecoveryKeyClick()
fun onRoomSettingsClick(roomId: RoomId)
fun onReportBugClick()
fun onLogoutForNativeSlidingSyncMigrationNeeded()
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -58,14 +59,7 @@ dependencies {
implementation(projects.libraries.previewutils)
api(projects.features.home.api)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs, true)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.features.networkmonitor.test)
@ -80,5 +74,4 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
}

View file

@ -164,7 +164,7 @@ class HomeFlowNode(
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
}
fun rootNode(buildContext: BuildContext): Node {
private fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier ->
val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current)

View file

@ -16,7 +16,7 @@
<string name="screen_home_tab_spaces">"Espaços"</string>
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
<string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
@ -33,6 +33,7 @@ Por enquanto, podes anular a seleção dos filtros para veres as tuas outras con
<string name="screen_roomlist_filter_invites">"Convites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Não tens nenhum convite pendente."</string>
<string name="screen_roomlist_filter_low_priority">"Prioridade baixa"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Ainda não tens conversas de prioridade baixa"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Podes anular a seleção dos filtros para veres as tuas outras conversas"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Não tens nenhuma conversa selecionada"</string>
<string name="screen_roomlist_filter_people">"Pessoas"</string>

View file

@ -0,0 +1,58 @@
/*
* 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.home.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultHomeEntryPointTest {
@Test
fun `test node builder`() {
val entryPoint = DefaultHomeEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
HomeFlowNode(
buildContext = buildContext,
plugins = plugins,
matrixClient = FakeMatrixClient(),
presenter = createHomePresenter(),
inviteFriendsUseCase = { lambdaError() },
analyticsService = FakeAnalyticsService(),
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
directLogoutView = { _ -> lambdaError() },
reportRoomEntryPoint = { _, _, _ -> lambdaError() },
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
changeRoomMemberRolesEntryPoint = { _, _ -> lambdaError() },
leaveRoomRenderer = { _, _, _ -> lambdaError() },
)
}
val callback = object : HomeEntryPoint.Callback {
override fun onRoomClick(roomId: RoomId) = lambdaError()
override fun onStartChatClick() = lambdaError()
override fun onSettingsClick() = lambdaError()
override fun onSetUpRecoveryClick() = lambdaError()
override fun onSessionConfirmRecoveryKeyClick() = lambdaError()
override fun onRoomSettingsClick(roomId: RoomId) = lambdaError()
override fun onReportBugClick() = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result).isInstanceOf(HomeFlowNode::class.java)
assertThat(result.plugins).contains(callback)
}
}

View file

@ -175,24 +175,24 @@ class HomePresenterTest {
assertThat(finalState.showNavigationBar).isFalse()
}
}
private fun createHomePresenter(
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
) = HomePresenter(
client = client,
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
)
}
internal fun createHomePresenter(
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter<HomeSpacesState> = Presenter { aHomeSpacesState() },
) = HomePresenter(
client = client,
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
featureFlagService = featureFlagService,
)

View file

@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
interface AcceptDeclineInviteView {
fun interface AcceptDeclineInviteView {
@Composable
fun Render(
state: AcceptDeclineInviteState,

View file

@ -12,6 +12,6 @@ import com.bumble.appyx.core.node.Node
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.architecture.FeatureEntryPoint
interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData): Node
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -36,17 +37,9 @@ dependencies {
implementation(projects.services.analytics.api)
implementation(projects.libraries.push.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs, true)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -35,7 +35,7 @@ class DeclineAndBlockPresenter(
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<DeclineAndBlockState> {
@AssistedFactory
interface Factory {
fun interface Factory {
fun create(inviteData: InviteData): DeclineAndBlockPresenter
}

View file

@ -7,7 +7,7 @@
<string name="screen_decline_and_block_title">"Rejeitar e bloquear"</string>
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_message">"Tens a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
<string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>

View file

@ -11,11 +11,11 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.impl.DeclineInvite
import io.element.android.features.invite.impl.fake.FakeDeclineInvite
import io.element.android.features.invite.test.anInviteData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -148,28 +148,16 @@ class DeclineAndBlockPresenterTest {
.isCalledOnce()
.with(value(A_ROOM_ID), value(true), value(false), value(""))
}
private fun anInviteData(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,
isDm: Boolean = false,
): InviteData {
return InviteData(
roomId = roomId,
roomName = name,
isDm = isDm,
)
}
private fun createDeclineAndBlockPresenter(
inviteData: InviteData = anInviteData(),
declineInvite: DeclineInvite = FakeDeclineInvite(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): DeclineAndBlockPresenter {
return DeclineAndBlockPresenter(
inviteData = inviteData,
declineInvite = declineInvite,
snackbarDispatcher = snackbarDispatcher,
)
}
}
internal fun createDeclineAndBlockPresenter(
inviteData: InviteData = anInviteData(),
declineInvite: DeclineInvite = FakeDeclineInvite(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
): DeclineAndBlockPresenter {
return DeclineAndBlockPresenter(
inviteData = inviteData,
declineInvite = declineInvite,
snackbarDispatcher = snackbarDispatcher,
)
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.impl.declineandblock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.test.anInviteData
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultDeclineAndBlockEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultDeclineAndBlockEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
DeclineAndBlockNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { inviteData -> createDeclineAndBlockPresenter() }
)
}
val inviteData = anInviteData()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
inviteData = inviteData
)
assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java)
assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData))
}
}

View file

@ -7,8 +7,11 @@
package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
interface InvitePeopleState {
val canInvite: Boolean
val isSearchActive: Boolean
val sendInvitesAction: AsyncAction<Unit>
val eventSink: (InvitePeopleEvents) -> Unit
}

View file

@ -8,28 +8,33 @@
package io.element.android.features.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
override val values: Sequence<InvitePeopleState>
get() = sequenceOf(
aPreviewInvitePeopleState(),
aPreviewInvitePeopleState(canInvite = true),
aPreviewInvitePeopleState(isSearchActive = true)
aPreviewInvitePeopleState(isSearchActive = true),
aPreviewInvitePeopleState(sendInvitesAction = AsyncAction.Loading),
)
}
private data class PreviewInvitePeopleState(
override val canInvite: Boolean,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val eventSink: (InvitePeopleEvents) -> Unit,
) : InvitePeopleState
private fun aPreviewInvitePeopleState(
canInvite: Boolean = false,
isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {},
) = PreviewInvitePeopleState(
canInvite = canInvite,
isSearchActive = isSearchActive,
sendInvitesAction = sendInvitesAction,
eventSink = eventSink
)

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -37,17 +38,8 @@ dependencies {
implementation(projects.services.apperror.api)
api(projects.features.invitepeople.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.services.apperror.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -23,9 +23,11 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
@ -73,6 +75,8 @@ class DefaultInvitePeoplePresenter(
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
if (joinedRoom == null) {
val result = matrixClient.getJoinedRoom(roomId)
@ -116,7 +120,7 @@ class DefaultInvitePeoplePresenter(
}
is InvitePeopleEvents.SendInvites -> {
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value)
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
}
}
is InvitePeopleEvents.CloseSearch -> {
@ -128,12 +132,13 @@ class DefaultInvitePeoplePresenter(
return DefaultInvitePeopleState(
room = room.map { },
canInvite = selectedUsers.value.isNotEmpty(),
canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
isSearchActive = searchActive,
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
eventSink = ::handleEvents,
)
}
@ -141,16 +146,21 @@ class DefaultInvitePeoplePresenter(
private fun CoroutineScope.sendInvites(
room: JoinedRoom,
selectedUsers: List<MatrixUser>,
sendInvitesAction: MutableState<AsyncAction<Unit>>,
) = launch {
val anyInviteFailed = selectedUsers
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
sendInvitesAction.runUpdatingState {
val anyInviteFailed = selectedUsers
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
if (anyInviteFailed) {
appErrorStateService.showError(
titleRes = CommonStrings.common_unable_to_invite_title,
bodyRes = CommonStrings.common_unable_to_invite_message,
)
if (anyInviteFailed) {
appErrorStateService.showError(
titleRes = CommonStrings.common_unable_to_invite_title,
bodyRes = CommonStrings.common_unable_to_invite_message,
)
}
Result.success(Unit)
}
}

View file

@ -9,6 +9,7 @@ package io.element.android.features.invitepeople.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -22,5 +23,6 @@ data class DefaultInvitePeopleState(
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -68,6 +69,11 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
showSearchLoader = true,
),
aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))),
aDefaultInvitePeopleState(
canInvite = false,
selectedUsers = aMatrixUserList().toImmutableList(),
sendInvitesAction = AsyncAction.Loading,
),
)
}
@ -93,6 +99,7 @@ private fun aDefaultInvitePeopleState(
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
): DefaultInvitePeopleState {
return DefaultInvitePeopleState(
room = room,
@ -102,6 +109,7 @@ private fun aDefaultInvitePeopleState(
selectedUsers = selectedUsers,
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction,
eventSink = {},
)
}

View file

@ -409,10 +409,23 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites)
// Can't invite in the loading state
awaitItem().run {
assertThat(sendInvitesAction.isLoading()).isTrue()
assertThat(canInvite).isFalse()
}
delay(1_000)
inviteUserResult.assertions().isCalledOnce().with(
value(selectedUser.userId)
)
// Can invite again once the action is finished
awaitItem().run {
assertThat(sendInvitesAction.isReady()).isTrue()
assertThat(canInvite).isTrue()
}
}
}
@ -445,6 +458,13 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites)
// Can't invite in the loading state
awaitItem().run {
assertThat(sendInvitesAction.isLoading()).isTrue()
assertThat(canInvite).isFalse()
}
delay(1_000)
inviteUserResult.assertions().isCalledOnce().with(
value(selectedUser.userId)
@ -455,6 +475,12 @@ internal class DefaultInvitePeoplePresenterTest {
value(CommonStrings.common_unable_to_invite_title),
value(CommonStrings.common_unable_to_invite_message)
)
// Can invite again once the action is finished
awaitItem().run {
assertThat(sendInvitesAction.isReady()).isTrue()
assertThat(canInvite).isTrue()
}
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2024 New Vector Ltd.
@ -38,16 +39,8 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.appconfig)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs, true)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.preferences.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -76,7 +76,7 @@ class JoinRoomPresenter(
private val buildMeta: BuildMeta,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> {
interface Factory {
fun interface Factory {
fun create(
roomId: RoomId,
roomIdOrAlias: RoomIdOrAlias,

View file

@ -20,7 +20,7 @@
<string name="screen_join_room_knock_action">"Bater à porta"</string>
<string name="screen_join_room_knock_message_characters_count">"%1$d de %2$d caracteres permitidos"</string>
<string name="screen_join_room_knock_message_description">"Mensagem (opcional)"</string>
<string name="screen_join_room_knock_sent_description">"Irá receber um convite para participar na sala se seu pedido for aceite."</string>
<string name="screen_join_room_knock_sent_description">"Irás receber um convite para participar na sala se o pedido for aceite."</string>
<string name="screen_join_room_knock_sent_title">"Pedido de adesão enviado"</string>
<string name="screen_join_room_loading_alert_message">"Não conseguimos exibir a pré-visualização da sala. Isso pode ser devido a problemas de rede ou servidor."</string>
<string name="screen_join_room_loading_alert_title">"Não foi possível exibir a pré-visualização desta sala"</string>

View file

@ -0,0 +1,59 @@
/*
* 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.joinroom.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
import java.util.Optional
class DefaultJoinRoomEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultJoinRoomEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
JoinRoomFlowNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _, _, _, _ -> createJoinRoomPresenter() },
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
declineAndBlockEntryPoint = object : DeclineInviteAndBlockEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext, inviteData: InviteData) = lambdaError()
}
)
}
val inputs = JoinRoomEntryPoint.Inputs(
roomId = A_ROOM_ID,
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
roomDescription = Optional.ofNullable(null),
serverNames = emptyList(),
trigger = JoinedRoom.Trigger.RoomDirectory,
)
val result = entryPoint.createNode(parentNode, BuildContext.root(null), inputs)
assertThat(result).isInstanceOf(JoinRoomFlowNode::class.java)
assertThat(result.plugins).contains(inputs)
}
}

View file

@ -1034,39 +1034,6 @@ class JoinRoomPresenterTest {
}
}
private fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(),
serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite,
matrixClient: MatrixClient = FakeMatrixClient(),
joinRoomLambda: (RoomIdOrAlias, List<String>, JoinedRoom.Trigger) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
knockRoom: KnockRoom = FakeKnockRoom(),
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
serverNames = serverNames,
trigger = trigger,
matrixClient = matrixClient,
joinRoom = FakeJoinRoom(joinRoomLambda),
knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
seenInvitesStore = seenInvitesStore,
)
}
private fun aRoomDescription(
roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME,
@ -1087,3 +1054,36 @@ class JoinRoomPresenterTest {
)
}
}
internal fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(),
serverNames: List<String> = emptyList(),
trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite,
matrixClient: MatrixClient = FakeMatrixClient(),
joinRoomLambda: (RoomIdOrAlias, List<String>, JoinedRoom.Trigger) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
knockRoom: KnockRoom = FakeKnockRoom(),
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
serverNames = serverNames,
trigger = trigger,
matrixClient = matrixClient,
joinRoom = FakeJoinRoom(joinRoomLambda),
knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
seenInvitesStore = seenInvitesStore,
)
}

View file

@ -6,6 +6,7 @@
*/
import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
@ -33,15 +34,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.featureflag.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -0,0 +1,36 @@
/*
* 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.knockrequests.impl.list
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultKnockRequestsListEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultKnockRequestsListEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
KnockRequestsListNode(
buildContext = buildContext,
plugins = plugins,
presenter = createKnockRequestsListPresenter(),
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(KnockRequestsListNode::class.java)
}
}

View file

@ -286,19 +286,19 @@ class KnockRequestsListPresenterTest {
assert(acceptFailureLambda).isCalledOnce()
assert(acceptSuccessLambda).isCalledOnce()
}
private fun TestScope.createKnockRequestsListPresenter(
canAccept: Boolean = true,
canDecline: Boolean = true,
canBan: Boolean = true,
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList())
): KnockRequestsListPresenter {
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
isKnockFeatureEnabledFlow = flowOf(true),
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
)
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
}
}
internal fun TestScope.createKnockRequestsListPresenter(
canAccept: Boolean = true,
canDecline: Boolean = true,
canBan: Boolean = true,
knockRequestsFlow: Flow<List<KnockRequest>> = flowOf(emptyList())
): KnockRequestsListPresenter {
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
isKnockFeatureEnabledFlow = flowOf(true),
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
)
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
}

View file

@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
interface LeaveRoomRenderer {
fun interface LeaveRoomRenderer {
@Composable
fun Render(
state: LeaveRoomState,

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -26,13 +27,8 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.push.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.tests.testutils)
}

View file

@ -7,9 +7,6 @@
package io.element.android.features.licenses.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface OpenSourceLicensesEntryPoint {
fun getNode(node: Node, buildContext: BuildContext): Node
}
interface OpenSourceLicensesEntryPoint : SimpleFeatureEntryPoint

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -26,12 +27,8 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
api(projects.features.licenses.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -18,7 +18,7 @@ import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@Inject
class DefaultOpenSourcesLicensesEntryPoint : OpenSourceLicensesEntryPoint {
override fun getNode(node: Node, buildContext: BuildContext): Node {
return node.createNode<DependenciesFlowNode>(buildContext)
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<DependenciesFlowNode>(buildContext)
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.licenses.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultOpenSourcesLicensesEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultOpenSourcesLicensesEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
DependenciesFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(DependenciesFlowNode::class.java)
}
}

View file

@ -8,6 +8,7 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
@ -70,6 +71,5 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -37,17 +38,10 @@ dependencies {
implementation(projects.services.analytics.api)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -10,7 +10,7 @@ package io.element.android.features.location.impl.common.permissions
import io.element.android.libraries.architecture.Presenter
interface PermissionsPresenter : Presenter<PermissionsState> {
interface Factory {
fun interface Factory {
fun create(permissions: List<String>): PermissionsPresenter
}
}

View file

@ -47,7 +47,7 @@ class SendLocationPresenter(
private val buildMeta: BuildMeta,
) : Presenter<SendLocationState> {
@AssistedFactory
interface Factory {
fun interface Factory {
fun create(timelineMode: Timeline.Mode): SendLocationPresenter
}

View file

@ -25,10 +25,10 @@ import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
@Inject
class ShowLocationNode(
presenterFactory: ShowLocationPresenter.Factory,
analyticsService: AnalyticsService,
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ShowLocationPresenter.Factory,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(

View file

@ -28,14 +28,14 @@ import io.element.android.libraries.core.meta.BuildMeta
@Inject
class ShowLocationPresenter(
@Assisted private val location: Location,
@Assisted private val description: String?,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
@Assisted private val location: Location,
@Assisted private val description: String?
) : Presenter<ShowLocationState> {
@AssistedFactory
interface Factory {
fun interface Factory {
fun create(location: Location, description: String?): ShowLocationPresenter
}

View file

@ -0,0 +1,55 @@
/*
* 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.location.impl.send
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultSendLocationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultSendLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
SendLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode ->
SendLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
room = FakeJoinedRoom(),
timelineMode = timelineMode,
analyticsService = FakeAnalyticsService(),
messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
)
},
analyticsService = FakeAnalyticsService(),
)
}
val timelineMode = Timeline.Mode.Live
val result = entryPoint.builder(timelineMode)
.build(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(SendLocationNode::class.java)
assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode))
}
}

View file

@ -0,0 +1,58 @@
/*
* 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.location.impl.show
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultShowLocationEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultShowLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { location: Location, description: String? ->
ShowLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
location = location,
description = description,
)
},
analyticsService = FakeAnalyticsService(),
)
}
val inputs = ShowLocationEntryPoint.Inputs(
location = Location(37.4219983, -122.084, 10f),
description = "My location",
)
val result = entryPoint.createNode(
parentNode,
BuildContext.root(null),
inputs = inputs,
)
assertThat(result).isInstanceOf(ShowLocationNode::class.java)
assertThat(result.plugins).contains(inputs)
}
}

View file

@ -37,10 +37,10 @@ class ShowLocationPresenterTest {
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permissions: List<String>): PermissionsPresenter = fakePermissionsPresenter
},
fakeLocationActions,
fakeBuildMeta,
location,
A_DESCRIPTION,
locationActions = fakeLocationActions,
buildMeta = fakeBuildMeta,
location = location,
description = A_DESCRIPTION,
)
@Test

View file

@ -10,7 +10,7 @@ package io.element.android.features.location.test
import io.element.android.features.location.api.LocationService
class FakeLocationService(
private val isServiceAvailable: Boolean,
private val isServiceAvailable: Boolean = false,
) : LocationService {
override fun isServiceAvailable() = isServiceAvailable
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -44,21 +45,12 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.biometric)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -174,7 +174,7 @@ class PinUnlockPresenter(
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<Unit>>) = launch {
suspend {
logoutUseCase.logout(ignoreSdkError = true)
logoutUseCase.logoutAll(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultLockScreenEntryPointIntentTest {
@Test
fun `test pin unlock intent`() {
val entryPoint = DefaultLockScreenEntryPoint()
val result = entryPoint.pinUnlockIntent(InstrumentationRegistry.getInstrumentation().context)
assertThat(result.component?.className).isEqualTo(PinUnlockActivity::class.qualifiedName)
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultLockScreenEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder Setup`() {
val entryPoint = DefaultLockScreenEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
LockScreenFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() = lambdaError()
}
val navTarget = LockScreenEntryPoint.Target.Setup
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null), navTarget)
.callback(callback)
.build()
assertThat(result).isInstanceOf(LockScreenFlowNode::class.java)
assertThat(result.plugins).contains(LockScreenFlowNode.Inputs(LockScreenFlowNode.NavTarget.Setup))
assertThat(result.plugins).contains(callback)
}
@Test
fun `test node builder Settings`() {
val entryPoint = DefaultLockScreenEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
LockScreenFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() = lambdaError()
}
val navTarget = LockScreenEntryPoint.Target.Settings
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null), navTarget)
.callback(callback)
.build()
assertThat(result).isInstanceOf(LockScreenFlowNode::class.java)
assertThat(result.plugins).contains(LockScreenFlowNode.Inputs(LockScreenFlowNode.NavTarget.Settings))
assertThat(result.plugins).contains(callback)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -48,14 +49,7 @@ dependencies {
implementation(libs.serialization.json)
api(projects.features.login.api)
testImplementation(libs.test.junit)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs, true)
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
@ -63,6 +57,4 @@ dependencies {
testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.tests.testutils)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -74,7 +74,7 @@ Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro disposi
<string name="screen_qr_code_login_initial_state_item_3">"Seleciona %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Ligar novo dispositivo”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Lê o código QR com este dispositivo"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Disponível apenas se o seu fornecedor de conta o suportar."</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Disponível apenas se o teu operador de conta o permitir."</string>
<string name="screen_qr_code_login_initial_state_title">"Abre a %1$s noutro dispositivo para obteres o código QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Lê o código QR apresentado no outro dispositivo."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Tentar novamente"</string>

View file

@ -0,0 +1,56 @@
/*
* 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.login.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultLoginEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultLoginEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
LoginFlowNode(
buildContext = buildContext,
plugins = plugins,
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
oidcActionFlow = FakeOidcActionFlow(),
)
}
val callback = object : LoginEntryPoint.Callback {
override fun onReportProblem() = lambdaError()
}
val params = LoginEntryPoint.Params(
accountProvider = "ac",
loginHint = "lh",
)
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.params(params)
.callback(callback)
.build()
assertThat(result).isInstanceOf(LoginFlowNode::class.java)
assertThat(result.plugins).contains(LoginFlowNode.Params(params.accountProvider, params.loginHint))
assertThat(result.plugins).contains(callback)
}
}

View file

@ -8,16 +8,12 @@
package io.element.android.features.logout.api
/**
* Used to trigger a log out of the current user from any part of the app.
* Used to trigger a log out of the current user(s) from any part of the app.
*/
interface LogoutUseCase {
/**
* Log out the current user and then perform any needed cleanup tasks.
* Log out the current user(s) and then perform any needed cleanup tasks.
* @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway.
*/
suspend fun logout(ignoreSdkError: Boolean)
interface Factory {
fun create(sessionId: String): LogoutUseCase
}
suspend fun logoutAll(ignoreSdkError: Boolean)
}

View file

@ -9,7 +9,7 @@ package io.element.android.features.logout.api.direct
import androidx.compose.runtime.Composable
interface DirectLogoutView {
fun interface DirectLogoutView {
@Composable
fun Render(state: DirectLogoutState)
}

View file

@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -35,15 +36,8 @@ dependencies {
implementation(projects.libraries.dateformatter.api)
api(projects.features.logout.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.sessionStorage.test)
}

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