mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-06 07:24:49 +00:00
Merge branch 'element-hq:develop' into feature/scroll-to-unread-messages
This commit is contained in:
+1
-1
@@ -71,7 +71,7 @@ fun FlowStepPage(
|
||||
},
|
||||
header = {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
modifier = Modifier.padding(bottom = 16.dp, start = 8.dp, end = 8.dp),
|
||||
title = title,
|
||||
subTitle = subTitle,
|
||||
iconStyle = iconStyle,
|
||||
|
||||
+9
-7
@@ -77,26 +77,28 @@ class DefaultPinnedMessagesBannerFormatter(
|
||||
messageType.toPlainText(permalinkParser)
|
||||
}
|
||||
is VideoMessageType -> {
|
||||
messageType.bestDescription.prefixWith(CommonStrings.common_video)
|
||||
messageType.toPlainText(permalinkParser).prefixWith(CommonStrings.common_video)
|
||||
}
|
||||
is ImageMessageType -> {
|
||||
messageType.bestDescription.prefixWith(CommonStrings.common_image)
|
||||
messageType.toPlainText(permalinkParser).prefixWith(CommonStrings.common_image)
|
||||
}
|
||||
is StickerMessageType -> {
|
||||
messageType.bestDescription.prefixWith(CommonStrings.common_sticker)
|
||||
messageType.toPlainText(permalinkParser).prefixWith(CommonStrings.common_sticker)
|
||||
}
|
||||
is LocationMessageType -> {
|
||||
messageType.body.prefixWith(CommonStrings.common_shared_location)
|
||||
}
|
||||
is FileMessageType -> {
|
||||
messageType.bestDescription.prefixWith(CommonStrings.common_file)
|
||||
messageType.toPlainText(permalinkParser).prefixWith(CommonStrings.common_file)
|
||||
}
|
||||
is AudioMessageType -> {
|
||||
messageType.bestDescription.prefixWith(CommonStrings.common_audio)
|
||||
messageType.toPlainText(permalinkParser).prefixWith(CommonStrings.common_audio)
|
||||
}
|
||||
is VoiceMessageType -> {
|
||||
// In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
|
||||
messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
|
||||
messageType
|
||||
.toPlainText(permalinkParser, "")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.prefixWith(sp.getString(CommonStrings.common_voice_message))
|
||||
?: sp.getString(CommonStrings.common_voice_message)
|
||||
}
|
||||
is OtherMessageType -> {
|
||||
|
||||
+9
-7
@@ -139,26 +139,28 @@ class DefaultRoomLatestEventFormatter(
|
||||
messageType.toPlainText(permalinkParser)
|
||||
}
|
||||
is VideoMessageType -> {
|
||||
messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_video))
|
||||
messageType.toPlainText(permalinkParser).prefixWith(sp.getString(CommonStrings.common_video))
|
||||
}
|
||||
is ImageMessageType -> {
|
||||
messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_image))
|
||||
messageType.toPlainText(permalinkParser).prefixWith(sp.getString(CommonStrings.common_image))
|
||||
}
|
||||
is StickerMessageType -> {
|
||||
messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_sticker))
|
||||
messageType.toPlainText(permalinkParser).prefixWith(sp.getString(CommonStrings.common_sticker))
|
||||
}
|
||||
is LocationMessageType -> {
|
||||
sp.getString(CommonStrings.common_shared_location)
|
||||
}
|
||||
is FileMessageType -> {
|
||||
messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_file))
|
||||
messageType.toPlainText(permalinkParser).prefixWith(sp.getString(CommonStrings.common_file))
|
||||
}
|
||||
is AudioMessageType -> {
|
||||
messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_audio))
|
||||
messageType.toPlainText(permalinkParser).prefixWith(sp.getString(CommonStrings.common_audio))
|
||||
}
|
||||
is VoiceMessageType -> {
|
||||
// In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
|
||||
messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
|
||||
messageType
|
||||
.toPlainText(permalinkParser, "")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.prefixWith(sp.getString(CommonStrings.common_voice_message))
|
||||
?: sp.getString(CommonStrings.common_voice_message)
|
||||
}
|
||||
is OtherMessageType -> {
|
||||
|
||||
@@ -44,14 +44,12 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.serialization.json)
|
||||
api(projects.libraries.sessionStorage.api)
|
||||
implementation(libs.coroutines.core)
|
||||
api(projects.libraries.architecture)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
|
||||
+1
-1
@@ -16,6 +16,6 @@ sealed class AuthenticationException(message: String?) : Exception(message) {
|
||||
class InvalidServerName(message: String?) : AuthenticationException(message)
|
||||
class SlidingSyncVersion(message: String?) : AuthenticationException(message)
|
||||
class ServerUnreachable(message: String?) : AuthenticationException(message)
|
||||
class Oidc(message: String?) : AuthenticationException(message)
|
||||
class OAuth(message: String?) : AuthenticationException(message)
|
||||
class Generic(message: String?) : AuthenticationException(message)
|
||||
}
|
||||
|
||||
+9
-9
@@ -37,21 +37,21 @@ interface MatrixAuthenticationService {
|
||||
suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId>
|
||||
|
||||
/*
|
||||
* OIDC part.
|
||||
* OAuth part.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the Oidc url to display to the user.
|
||||
* Get the OAuth url to display to the user.
|
||||
*/
|
||||
suspend fun getOidcUrl(
|
||||
prompt: OidcPrompt,
|
||||
suspend fun getOAuthUrl(
|
||||
prompt: OAuthPrompt,
|
||||
loginHint: String?,
|
||||
): Result<OidcDetails>
|
||||
): Result<OAuthDetails>
|
||||
|
||||
/**
|
||||
* Cancel Oidc login sequence.
|
||||
* Cancel OAuth login sequence.
|
||||
*/
|
||||
suspend fun cancelOidcLogin(): Result<Unit>
|
||||
suspend fun cancelOAuthLogin(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Set the existing data about Element Classic session, if any.
|
||||
@@ -68,9 +68,9 @@ interface MatrixAuthenticationService {
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Attempt to login using the [callbackUrl] provided by the Oidc page.
|
||||
* Attempt to log in using the [callbackUrl] provided by the OAuth page.
|
||||
*/
|
||||
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
|
||||
suspend fun loginWithOAuth(callbackUrl: String): Result<SessionId>
|
||||
|
||||
suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId>
|
||||
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.api.auth
|
||||
data class MatrixHomeServerDetails(
|
||||
val url: String,
|
||||
val supportsPasswordLogin: Boolean,
|
||||
val supportsOidcLogin: Boolean,
|
||||
val supportsOAuthLogin: Boolean,
|
||||
) {
|
||||
val isSupported = supportsPasswordLogin || supportsOidcLogin
|
||||
val isSupported = supportsPasswordLogin || supportsOAuthLogin
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
import io.element.android.libraries.matrix.api.BuildConfig
|
||||
|
||||
object OidcConfig {
|
||||
object OAuthConfig {
|
||||
const val CLIENT_URI = BuildConfig.CLIENT_URI
|
||||
|
||||
// Note: host must match with the host of CLIENT_URI
|
||||
+1
-1
@@ -12,6 +12,6 @@ import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class OidcDetails(
|
||||
data class OAuthDetails(
|
||||
val url: String,
|
||||
) : Parcelable
|
||||
+4
-4
@@ -8,12 +8,12 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
sealed interface OidcPrompt {
|
||||
sealed interface OAuthPrompt {
|
||||
/**
|
||||
* The Authorization Server should prompt the End-User for
|
||||
* reauthentication.
|
||||
*/
|
||||
data object Login : OidcPrompt
|
||||
data object Login : OAuthPrompt
|
||||
|
||||
/**
|
||||
* The Authorization Server should prompt the End-User to create a user
|
||||
@@ -21,10 +21,10 @@ sealed interface OidcPrompt {
|
||||
*
|
||||
* Defined in [Initiating User Registration via OpenID Connect](https://openid.net/specs/openid-connect-prompt-create-1_0.html).
|
||||
*/
|
||||
data object Create : OidcPrompt
|
||||
data object Create : OAuthPrompt
|
||||
|
||||
/**
|
||||
* An unknown value.
|
||||
*/
|
||||
data class Unknown(val value: String) : OidcPrompt
|
||||
data class Unknown(val value: String) : OAuthPrompt
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
interface OidcRedirectUrlProvider {
|
||||
interface OAuthRedirectUrlProvider {
|
||||
fun provide(): String
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ sealed class QrLoginException : Exception() {
|
||||
data object Expired : QrLoginException()
|
||||
data object NotFound : QrLoginException()
|
||||
data object LinkingNotSupported : QrLoginException()
|
||||
data object OidcMetadataInvalid : QrLoginException()
|
||||
data object OAuthMetadataInvalid : QrLoginException()
|
||||
data object SlidingSyncNotAvailable : QrLoginException()
|
||||
data object OtherDeviceNotSignedIn : QrLoginException()
|
||||
data object CheckCodeAlreadySent : QrLoginException()
|
||||
|
||||
+4
-4
@@ -112,19 +112,19 @@ interface IdentityPasswordResetHandle : IdentityResetHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle to reset the user's identity with an OIDC login type.
|
||||
* A handle to reset the user's identity with an OAuth login type.
|
||||
*/
|
||||
interface IdentityOidcResetHandle : IdentityResetHandle {
|
||||
interface IdentityOAuthResetHandle : IdentityResetHandle {
|
||||
/**
|
||||
* The URL to open in a webview/custom tab to reset the identity.
|
||||
*/
|
||||
val url: String
|
||||
|
||||
/**
|
||||
* Reset the identity using the OIDC flow.
|
||||
* Reset the identity using the OAuth flow.
|
||||
*
|
||||
* This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is
|
||||
* called, or the identity is reset.
|
||||
*/
|
||||
suspend fun resetOidc(): Result<Unit>
|
||||
suspend fun resetOAuth(): Result<Unit>
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.oidc
|
||||
package io.element.android.libraries.matrix.api.oauth
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ class MatrixHomeServerDetailsTest {
|
||||
@Test
|
||||
fun `if homeserver supports oidc, then it is supported`() {
|
||||
val sut = aMatrixHomeServerDetails(
|
||||
supportsOidcLogin = true,
|
||||
supportsOAuthLogin = true,
|
||||
supportsPasswordLogin = false,
|
||||
)
|
||||
assertThat(sut.isSupported).isTrue()
|
||||
@@ -25,7 +25,7 @@ class MatrixHomeServerDetailsTest {
|
||||
@Test
|
||||
fun `if homeserver supports password, then it is supported`() {
|
||||
val sut = aMatrixHomeServerDetails(
|
||||
supportsOidcLogin = false,
|
||||
supportsOAuthLogin = false,
|
||||
supportsPasswordLogin = true,
|
||||
)
|
||||
assertThat(sut.isSupported).isTrue()
|
||||
@@ -34,7 +34,7 @@ class MatrixHomeServerDetailsTest {
|
||||
@Test
|
||||
fun `if homeserver supports both, then it is supported`() {
|
||||
val sut = aMatrixHomeServerDetails(
|
||||
supportsOidcLogin = true,
|
||||
supportsOAuthLogin = true,
|
||||
supportsPasswordLogin = true,
|
||||
)
|
||||
assertThat(sut.isSupported).isTrue()
|
||||
@@ -43,7 +43,7 @@ class MatrixHomeServerDetailsTest {
|
||||
@Test
|
||||
fun `if homeserver supports none, then it is not supported`() {
|
||||
val sut = aMatrixHomeServerDetails(
|
||||
supportsOidcLogin = false,
|
||||
supportsOAuthLogin = false,
|
||||
supportsPasswordLogin = false,
|
||||
)
|
||||
assertThat(sut.isSupported).isFalse()
|
||||
|
||||
@@ -32,10 +32,12 @@ dependencies {
|
||||
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.workmanager.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
@@ -59,7 +59,7 @@ import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.impl.oidc.toRustAction
|
||||
import io.element.android.libraries.matrix.impl.oauth.toRustAction
|
||||
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
||||
import io.element.android.libraries.matrix.impl.room.GetRoomResult
|
||||
import io.element.android.libraries.matrix.impl.room.NotJoinedRustRoom
|
||||
|
||||
+1
-1
@@ -214,5 +214,5 @@ fun SessionData.toSession() = Session(
|
||||
deviceId = deviceId,
|
||||
homeserverUrl = homeserverUrl,
|
||||
slidingSyncVersion = SlidingSyncVersion.NATIVE,
|
||||
oauthData = oidcData,
|
||||
oauthData = oAuthData,
|
||||
)
|
||||
|
||||
+5
-5
@@ -30,11 +30,11 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
|
||||
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
|
||||
}
|
||||
is OAuthException -> when (this) {
|
||||
is OAuthException.Generic -> AuthenticationException.Oidc(message)
|
||||
is OAuthException.CallbackUrlInvalid -> AuthenticationException.Oidc(message)
|
||||
is OAuthException.Cancelled -> AuthenticationException.Oidc(message)
|
||||
is OAuthException.MetadataInvalid -> AuthenticationException.Oidc(message)
|
||||
is OAuthException.NotSupported -> AuthenticationException.Oidc(message)
|
||||
is OAuthException.Generic -> AuthenticationException.OAuth(message)
|
||||
is OAuthException.CallbackUrlInvalid -> AuthenticationException.OAuth(message)
|
||||
is OAuthException.Cancelled -> AuthenticationException.OAuth(message)
|
||||
is OAuthException.MetadataInvalid -> AuthenticationException.OAuth(message)
|
||||
is OAuthException.NotSupported -> AuthenticationException.OAuth(message)
|
||||
}
|
||||
else -> AuthenticationException.Generic(message)
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
|
||||
MatrixHomeServerDetails(
|
||||
url = url(),
|
||||
supportsPasswordLogin = supportsPasswordLogin(),
|
||||
supportsOidcLogin = supportsOauthLogin(),
|
||||
supportsOAuthLogin = supportsOauthLogin(),
|
||||
)
|
||||
}
|
||||
|
||||
+10
-10
@@ -10,22 +10,22 @@ package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthConfig
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthRedirectUrlProvider
|
||||
import org.matrix.rustcomponents.sdk.OAuthConfiguration
|
||||
|
||||
@Inject
|
||||
class OidcConfigurationProvider(
|
||||
class OAuthConfigurationProvider(
|
||||
private val buildMeta: BuildMeta,
|
||||
private val oidcRedirectUrlProvider: OidcRedirectUrlProvider,
|
||||
private val oAuthRedirectUrlProvider: OAuthRedirectUrlProvider,
|
||||
) {
|
||||
fun get(): OAuthConfiguration = OAuthConfiguration(
|
||||
clientName = buildMeta.applicationName,
|
||||
redirectUri = oidcRedirectUrlProvider.provide(),
|
||||
clientUri = OidcConfig.CLIENT_URI,
|
||||
logoUri = OidcConfig.LOGO_URI,
|
||||
tosUri = OidcConfig.TOS_URI,
|
||||
policyUri = OidcConfig.POLICY_URI,
|
||||
staticRegistrations = OidcConfig.STATIC_REGISTRATIONS,
|
||||
redirectUri = oAuthRedirectUrlProvider.provide(),
|
||||
clientUri = OAuthConfig.CLIENT_URI,
|
||||
logoUri = OAuthConfig.LOGO_URI,
|
||||
tosUri = OAuthConfig.TOS_URI,
|
||||
policyUri = OAuthConfig.POLICY_URI,
|
||||
staticRegistrations = OAuthConfig.STATIC_REGISTRATIONS,
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -8,13 +8,13 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import io.element.android.libraries.matrix.api.auth.OidcPrompt
|
||||
import org.matrix.rustcomponents.sdk.OAuthPrompt as RustOidcPrompt
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
|
||||
import org.matrix.rustcomponents.sdk.OAuthPrompt as RustOAuthPrompt
|
||||
|
||||
internal fun OidcPrompt.toRustPrompt(): RustOidcPrompt {
|
||||
internal fun OAuthPrompt.toRustPrompt(): RustOAuthPrompt {
|
||||
return when (this) {
|
||||
OidcPrompt.Login -> RustOidcPrompt.Unknown("consent")
|
||||
OidcPrompt.Create -> RustOidcPrompt.Create
|
||||
is OidcPrompt.Unknown -> RustOidcPrompt.Unknown(value)
|
||||
OAuthPrompt.Login -> RustOAuthPrompt.Unknown("consent")
|
||||
OAuthPrompt.Create -> RustOAuthPrompt.Create
|
||||
is OAuthPrompt.Unknown -> RustOAuthPrompt.Unknown(value)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ class RustHomeServerLoginCompatibilityChecker(
|
||||
it.homeserverLoginDetails()
|
||||
}
|
||||
.use {
|
||||
Timber.d("Homeserver $url | OIDC: ${it.supportsOauthLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}")
|
||||
Timber.d("Homeserver $url | OAuth: ${it.supportsOauthLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}")
|
||||
it.supportsOauthLogin() || it.supportsPasswordLogin()
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -19,8 +19,8 @@ import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcPrompt
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
|
||||
import io.element.android.libraries.matrix.api.auth.SessionRestorationException
|
||||
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
@@ -65,7 +65,7 @@ class RustMatrixAuthenticationService(
|
||||
private val sessionStore: SessionStore,
|
||||
private val rustMatrixClientFactory: RustMatrixClientFactory,
|
||||
private val passphraseGenerator: PassphraseGenerator,
|
||||
private val oidcConfigurationProvider: OidcConfigurationProvider,
|
||||
private val oAuthConfigurationProvider: OAuthConfigurationProvider,
|
||||
) : MatrixAuthenticationService {
|
||||
// Any existing Element Classic session that we want to try to import secrets from during login.
|
||||
private var elementClassicSession: ElementClassicSession? = null
|
||||
@@ -253,15 +253,15 @@ class RustMatrixAuthenticationService(
|
||||
|
||||
private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null
|
||||
|
||||
override suspend fun getOidcUrl(
|
||||
prompt: OidcPrompt,
|
||||
override suspend fun getOAuthUrl(
|
||||
prompt: OAuthPrompt,
|
||||
loginHint: String?,
|
||||
): Result<OidcDetails> {
|
||||
): Result<OAuthDetails> {
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||
val oAuthAuthorizationData = client.urlForOauth(
|
||||
oauthConfiguration = oidcConfigurationProvider.get(),
|
||||
oauthConfiguration = oAuthConfigurationProvider.get(),
|
||||
prompt = prompt.toRustPrompt(),
|
||||
loginHint = loginHint,
|
||||
// If we want to restore a previous session for which we have encryption keys, we can pass the deviceId here. At the moment, we don't
|
||||
@@ -270,15 +270,15 @@ class RustMatrixAuthenticationService(
|
||||
)
|
||||
val url = oAuthAuthorizationData.loginUrl()
|
||||
pendingOAuthAuthorizationData = oAuthAuthorizationData
|
||||
OidcDetails(url)
|
||||
OAuthDetails(url)
|
||||
}.mapFailure { failure ->
|
||||
Timber.e(failure, "Failed to get OIDC URL")
|
||||
Timber.e(failure, "Failed to get OAuth URL")
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelOidcLogin(): Result<Unit> {
|
||||
override suspend fun cancelOAuthLogin(): Result<Unit> {
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
pendingOAuthAuthorizationData?.use {
|
||||
@@ -286,7 +286,7 @@ class RustMatrixAuthenticationService(
|
||||
}
|
||||
pendingOAuthAuthorizationData = null
|
||||
}.mapFailure { failure ->
|
||||
Timber.e(failure, "Failed to cancel OIDC login")
|
||||
Timber.e(failure, "Failed to cancel OAuth login")
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}
|
||||
@@ -297,9 +297,9 @@ class RustMatrixAuthenticationService(
|
||||
}
|
||||
|
||||
/**
|
||||
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
|
||||
* callbackUrl should be the `url` from `OAuthAction` (with all the parameters).
|
||||
*/
|
||||
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
|
||||
override suspend fun loginWithOAuth(callbackUrl: String): Result<SessionId> {
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||
@@ -330,7 +330,7 @@ class RustMatrixAuthenticationService(
|
||||
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
Timber.e(failure, "Failed to login with OIDC")
|
||||
Timber.e(failure, "Failed to login with OAuth")
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ class RustMatrixAuthenticationService(
|
||||
withContext(coroutineDispatchers.io) {
|
||||
val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData
|
||||
val emptySessionPaths = rotateSessionPath()
|
||||
val oidcConfiguration = oidcConfigurationProvider.get()
|
||||
val oAuthConfiguration = oAuthConfigurationProvider.get()
|
||||
val progressListener = object : QrLoginProgressListener {
|
||||
override fun onUpdate(state: QrLoginProgress) {
|
||||
Timber.d("QR Code login progress: $state")
|
||||
@@ -368,7 +368,7 @@ class RustMatrixAuthenticationService(
|
||||
qrCodeData = sdkQrCodeLoginData,
|
||||
)
|
||||
client.newLoginWithQrCodeHandler(
|
||||
oauthConfiguration = oidcConfiguration,
|
||||
oauthConfiguration = oAuthConfiguration,
|
||||
).use {
|
||||
it.scan(
|
||||
qrCodeData = qrCodeData.rustQrCodeData,
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ object QrErrorMapper {
|
||||
is RustHumanQrLoginException.OtherDeviceNotSignedIn -> QrLoginException.OtherDeviceNotSignedIn
|
||||
is RustHumanQrLoginException.LinkingNotSupported -> QrLoginException.LinkingNotSupported
|
||||
is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown
|
||||
is RustHumanQrLoginException.OAuthMetadataInvalid -> QrLoginException.OidcMetadataInvalid
|
||||
is RustHumanQrLoginException.OAuthMetadataInvalid -> QrLoginException.OAuthMetadataInvalid
|
||||
is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable
|
||||
is RustHumanQrLoginException.CheckCodeAlreadySent -> QrLoginException.CheckCodeAlreadySent
|
||||
is RustHumanQrLoginException.CheckCodeCannotBeSent -> QrLoginException.CheckCodeCannotBeSent
|
||||
|
||||
+5
-5
@@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.encryption
|
||||
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOAuthResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import org.matrix.rustcomponents.sdk.AuthData
|
||||
@@ -25,7 +25,7 @@ object RustIdentityResetHandleFactory {
|
||||
return runCatchingExceptions {
|
||||
identityResetHandle?.let {
|
||||
when (val authType = identityResetHandle.authType()) {
|
||||
is CrossSigningResetAuthType.OAuth -> RustOidcIdentityResetHandle(identityResetHandle, authType.info.approvalUrl)
|
||||
is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl)
|
||||
// User interactive authentication (user + password)
|
||||
CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle)
|
||||
}
|
||||
@@ -47,11 +47,11 @@ class RustPasswordIdentityResetHandle(
|
||||
}
|
||||
}
|
||||
|
||||
class RustOidcIdentityResetHandle(
|
||||
class RustIdentityOAuthResetHandle(
|
||||
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
|
||||
override val url: String,
|
||||
) : IdentityOidcResetHandle {
|
||||
override suspend fun resetOidc(): Result<Unit> {
|
||||
) : IdentityOAuthResetHandle {
|
||||
override suspend fun resetOAuth(): Result<Unit> {
|
||||
return runCatchingExceptions { identityResetHandle.reset(null) }
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -54,6 +54,8 @@ class RustLinkDesktopHandler(
|
||||
}
|
||||
}
|
||||
)
|
||||
// We emit Done in case the progress listener was deallocated before scan() sent the Done
|
||||
_linkDesktopStep.emit(LinkDesktopStep.Done)
|
||||
} catch (e: QrCodeDecodeException) {
|
||||
Timber.tag(tag.value).w(e, "Invalid QR code scanned")
|
||||
_linkDesktopStep.emit(
|
||||
|
||||
+2
@@ -49,6 +49,8 @@ class RustLinkMobileHandler(
|
||||
}
|
||||
}
|
||||
)
|
||||
// We emit Done in case the progress listener was deallocated before generate() sent the Done
|
||||
_linkMobileStep.emit(LinkMobileStep.Done)
|
||||
} catch (e: HumanQrGrantLoginException) {
|
||||
Timber.tag(tag.value).w(e, "Error during QR login grant")
|
||||
_linkMobileStep.emit(LinkMobileStep.Error(e.map()))
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ internal fun Session.toSessionData(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl ?: this.homeserverUrl,
|
||||
oidcData = oauthData,
|
||||
oAuthData = oauthData,
|
||||
loginTimestamp = Date(),
|
||||
isTokenValid = isTokenValid,
|
||||
loginType = loginType,
|
||||
@@ -52,7 +52,7 @@ internal fun ExternalSession.toSessionData(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
oidcData = null,
|
||||
oAuthData = null,
|
||||
loginTimestamp = Date(),
|
||||
isTokenValid = isTokenValid,
|
||||
loginType = loginType,
|
||||
|
||||
+2
-2
@@ -6,9 +6,9 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.oidc
|
||||
package io.element.android.libraries.matrix.impl.oauth
|
||||
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
|
||||
import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction
|
||||
|
||||
fun AccountManagementAction.toRustAction(): RustAccountManagementAction {
|
||||
+6
-6
@@ -64,17 +64,17 @@ class AuthenticationExceptionMappingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapping Oidc exceptions map to the Oidc Kotlin`() {
|
||||
fun `mapping Oidc exceptions map to the OAuth Kotlin`() {
|
||||
assertThat(OAuthException.Generic("Generic").mapAuthenticationException())
|
||||
.isException<AuthenticationException.Oidc>("Generic")
|
||||
.isException<AuthenticationException.OAuth>("Generic")
|
||||
assertThat(OAuthException.CallbackUrlInvalid("CallbackUrlInvalid").mapAuthenticationException())
|
||||
.isException<AuthenticationException.Oidc>("CallbackUrlInvalid")
|
||||
.isException<AuthenticationException.OAuth>("CallbackUrlInvalid")
|
||||
assertThat(OAuthException.Cancelled("Cancelled").mapAuthenticationException())
|
||||
.isException<AuthenticationException.Oidc>("Cancelled")
|
||||
.isException<AuthenticationException.OAuth>("Cancelled")
|
||||
assertThat(OAuthException.MetadataInvalid("MetadataInvalid").mapAuthenticationException())
|
||||
.isException<AuthenticationException.Oidc>("MetadataInvalid")
|
||||
.isException<AuthenticationException.OAuth>("MetadataInvalid")
|
||||
assertThat(OAuthException.NotSupported("NotSupported").mapAuthenticationException())
|
||||
.isException<AuthenticationException.Oidc>("NotSupported")
|
||||
.isException<AuthenticationException.OAuth>("NotSupported")
|
||||
}
|
||||
|
||||
private inline fun <reified T> ThrowableSubject.isException(message: String) {
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ class HomeserverDetailsKtTest {
|
||||
val homeserverLoginDetails = FakeFfiHomeserverLoginDetails(
|
||||
url = "https://example.org",
|
||||
supportsPasswordLogin = true,
|
||||
supportsOidcLogin = false
|
||||
supportsOAuthLogin = false
|
||||
)
|
||||
|
||||
// When
|
||||
@@ -31,7 +31,7 @@ class HomeserverDetailsKtTest {
|
||||
MatrixHomeServerDetails(
|
||||
url = "https://example.org",
|
||||
supportsPasswordLogin = true,
|
||||
supportsOidcLogin = false
|
||||
supportsOAuthLogin = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,18 +10,18 @@ package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.auth.FAKE_REDIRECT_URL
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import org.junit.Test
|
||||
|
||||
class OidcConfigurationProviderTest {
|
||||
class OAuthConfigurationProviderTest {
|
||||
@Test
|
||||
fun get() {
|
||||
val result = OidcConfigurationProvider(
|
||||
val result = OAuthConfigurationProvider(
|
||||
buildMeta = aBuildMeta(
|
||||
applicationName = "myName",
|
||||
),
|
||||
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
|
||||
oAuthRedirectUrlProvider = FakeOAuthRedirectUrlProvider(),
|
||||
).get()
|
||||
assertThat(result.clientName).isEqualTo("myName")
|
||||
assertThat(result.redirectUri).isEqualTo(FAKE_REDIRECT_URL)
|
||||
+2
-2
@@ -18,8 +18,8 @@ import org.junit.Test
|
||||
|
||||
class RustHomeserverLoginCompatibilityCheckerTest {
|
||||
@Test
|
||||
fun `check - is valid if it supports OIDC login`() = runTest {
|
||||
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsOidcLogin = true) }
|
||||
fun `check - is valid if it supports OAuth login`() = runTest {
|
||||
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsOAuthLogin = true) }
|
||||
assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue()
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails
|
||||
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
@@ -64,9 +64,9 @@ class RustMatrixAuthenticationServiceTest {
|
||||
sessionStore = sessionStore,
|
||||
rustMatrixClientFactory = rustMatrixClientFactory,
|
||||
passphraseGenerator = FakePassphraseGenerator(),
|
||||
oidcConfigurationProvider = OidcConfigurationProvider(
|
||||
oAuthConfigurationProvider = OAuthConfigurationProvider(
|
||||
buildMeta = aBuildMeta(),
|
||||
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
|
||||
oAuthRedirectUrlProvider = FakeOAuthRedirectUrlProvider(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ class QrErrorMapperTest {
|
||||
assertThat(QrErrorMapper.map(RustHumanQrLoginException.OtherDeviceNotSignedIn())).isEqualTo(QrLoginException.OtherDeviceNotSignedIn)
|
||||
assertThat(QrErrorMapper.map(RustHumanQrLoginException.LinkingNotSupported())).isEqualTo(QrLoginException.LinkingNotSupported)
|
||||
assertThat(QrErrorMapper.map(RustHumanQrLoginException.Unknown())).isEqualTo(QrLoginException.Unknown)
|
||||
assertThat(QrErrorMapper.map(RustHumanQrLoginException.OAuthMetadataInvalid())).isEqualTo(QrLoginException.OidcMetadataInvalid)
|
||||
assertThat(QrErrorMapper.map(RustHumanQrLoginException.OAuthMetadataInvalid())).isEqualTo(QrLoginException.OAuthMetadataInvalid)
|
||||
assertThat(QrErrorMapper.map(RustHumanQrLoginException.SlidingSyncNotAvailable())).isEqualTo(QrLoginException.SlidingSyncNotAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import org.matrix.rustcomponents.sdk.QrCodeData
|
||||
|
||||
class FakeFfiGrantLoginWithQrCodeHandler(
|
||||
private val generateResult: () -> Unit = {},
|
||||
private val scanResult: (QrCodeData) -> Unit = {},
|
||||
private val generateResult: suspend () -> Unit = {},
|
||||
private val scanResult: suspend (QrCodeData) -> Unit = {},
|
||||
) : GrantLoginWithQrCodeHandler(NoHandle) {
|
||||
private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null
|
||||
private var scanProgressListener: GrantQrLoginProgressListener? = null
|
||||
|
||||
+2
-2
@@ -14,11 +14,11 @@ import org.matrix.rustcomponents.sdk.NoHandle
|
||||
class FakeFfiHomeserverLoginDetails(
|
||||
private val url: String = "https://example.org",
|
||||
private val supportsPasswordLogin: Boolean = false,
|
||||
private val supportsOidcLogin: Boolean = false,
|
||||
private val supportsOAuthLogin: Boolean = false,
|
||||
private val supportsSsoLogin: Boolean = false,
|
||||
) : HomeserverLoginDetails(NoHandle) {
|
||||
override fun url(): String = url
|
||||
override fun supportsOauthLogin(): Boolean = supportsOidcLogin
|
||||
override fun supportsOauthLogin(): Boolean = supportsOAuthLogin
|
||||
override fun supportsPasswordLogin(): Boolean = supportsPasswordLogin
|
||||
override fun supportsSsoLogin(): Boolean = supportsSsoLogin
|
||||
}
|
||||
|
||||
+38
-1
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
||||
import io.element.android.libraries.matrix.test.QR_CODE_DATA
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -29,7 +30,13 @@ import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
||||
class RustLinkDesktopHandlerTest {
|
||||
@Test
|
||||
fun `handleScannedQrCode function works as expected`() = runTest {
|
||||
val handler = FakeFfiGrantLoginWithQrCodeHandler()
|
||||
val completable = CompletableDeferred<Unit>()
|
||||
val handler = FakeFfiGrantLoginWithQrCodeHandler(
|
||||
scanResult = {
|
||||
// Ensure that the coroutine is hold
|
||||
completable.await()
|
||||
}
|
||||
)
|
||||
val sut = createRustLinkDesktopHandler(
|
||||
handler,
|
||||
)
|
||||
@@ -53,6 +60,36 @@ class RustLinkDesktopHandlerTest {
|
||||
handler.emitScanProgress(progress)
|
||||
assertThat(awaitItem()).isEqualTo(expectedStep)
|
||||
}
|
||||
// scan returns, no new event is emitted
|
||||
completable.complete(Unit)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when scan does not emits the Done state, the code emits it`() = runTest {
|
||||
val completable = CompletableDeferred<Unit>()
|
||||
val handler = FakeFfiGrantLoginWithQrCodeHandler(
|
||||
scanResult = {
|
||||
// Ensure that the coroutine is hold
|
||||
completable.await()
|
||||
}
|
||||
)
|
||||
val sut = createRustLinkDesktopHandler(
|
||||
handler,
|
||||
)
|
||||
sut.linkDesktopStep.test {
|
||||
val initialItem = awaitItem()
|
||||
assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized)
|
||||
backgroundScope.launch {
|
||||
sut.handleScannedQrCode(QR_CODE_DATA)
|
||||
}
|
||||
runCurrent()
|
||||
handler.emitScanProgress(GrantQrLoginProgress.Starting)
|
||||
assertThat(awaitItem()).isEqualTo(LinkDesktopStep.Starting)
|
||||
// scan returns, Done event is emitted
|
||||
completable.complete(Unit)
|
||||
assertThat(awaitItem()).isEqualTo(LinkDesktopStep.Done)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-1
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeS
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
|
||||
import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -30,7 +31,13 @@ import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException
|
||||
class RustLinkMobileHandlerTest {
|
||||
@Test
|
||||
fun `start function works as expected`() = runTest {
|
||||
val handler = FakeFfiGrantLoginWithQrCodeHandler()
|
||||
val completable = CompletableDeferred<Unit>()
|
||||
val handler = FakeFfiGrantLoginWithQrCodeHandler(
|
||||
generateResult = {
|
||||
// Ensure that the coroutine is hold
|
||||
completable.await()
|
||||
}
|
||||
)
|
||||
val sut = createRustLinkMobileHandler(
|
||||
handler,
|
||||
)
|
||||
@@ -56,6 +63,36 @@ class RustLinkMobileHandlerTest {
|
||||
handler.emitGenerateProgress(progress)
|
||||
assertThat(awaitItem()).isInstanceOf(expectedStepClass)
|
||||
}
|
||||
// generate returns, no new event is emitted
|
||||
completable.complete(Unit)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when generates does not emits the Done state, the code emits it`() = runTest {
|
||||
val completable = CompletableDeferred<Unit>()
|
||||
val handler = FakeFfiGrantLoginWithQrCodeHandler(
|
||||
generateResult = {
|
||||
// Ensure that the coroutine is hold
|
||||
completable.await()
|
||||
}
|
||||
)
|
||||
val sut = createRustLinkMobileHandler(
|
||||
handler,
|
||||
)
|
||||
sut.linkMobileStep.test {
|
||||
val initialItem = awaitItem()
|
||||
assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
|
||||
backgroundScope.launch {
|
||||
sut.start()
|
||||
}
|
||||
runCurrent()
|
||||
handler.emitGenerateProgress(GrantGeneratedQrLoginProgress.Starting)
|
||||
assertThat(awaitItem()).isEqualTo(LinkMobileStep.Starting)
|
||||
// generate returns, Done event is emitted
|
||||
completable.complete(Unit)
|
||||
assertThat(awaitItem()).isEqualTo(LinkMobileStep.Done)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -36,7 +36,7 @@ class SessionKtTest {
|
||||
assertThat(result.refreshToken).isEqualTo("refreshToken")
|
||||
assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL)
|
||||
assertThat(result.isTokenValid).isTrue()
|
||||
assertThat(result.oidcData).isNull()
|
||||
assertThat(result.oAuthData).isNull()
|
||||
assertThat(result.loginType).isEqualTo(LoginType.PASSWORD)
|
||||
assertThat(result.loginTimestamp).isNotNull()
|
||||
assertThat(result.passphrase).isEqualTo(A_SECRET)
|
||||
@@ -82,7 +82,7 @@ class SessionKtTest {
|
||||
assertThat(result.refreshToken).isNull()
|
||||
assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL)
|
||||
assertThat(result.isTokenValid).isTrue()
|
||||
assertThat(result.oidcData).isNull()
|
||||
assertThat(result.oAuthData).isNull()
|
||||
assertThat(result.loginType).isEqualTo(LoginType.PASSWORD)
|
||||
assertThat(result.loginTimestamp).isNotNull()
|
||||
assertThat(result.passphrase).isEqualTo(A_SECRET)
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.oidc
|
||||
package io.element.android.libraries.matrix.impl.oauth
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction
|
||||
@@ -19,6 +19,7 @@ dependencies {
|
||||
api(projects.libraries.matrix.api)
|
||||
api(libs.coroutines.core)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationService
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
|
||||
+16
-16
@@ -12,8 +12,8 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcPrompt
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
|
||||
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||
@@ -26,7 +26,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
||||
val A_OIDC_DATA = OidcDetails(url = "a-url")
|
||||
val AN_OAUTH_DATA = OAuthDetails(url = "a-url")
|
||||
|
||||
class FakeMatrixAuthenticationService(
|
||||
var matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null,
|
||||
@@ -37,8 +37,8 @@ class FakeMatrixAuthenticationService(
|
||||
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
|
||||
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
|
||||
) : MatrixAuthenticationService {
|
||||
private var oidcError: Throwable? = null
|
||||
private var oidcCancelError: Throwable? = null
|
||||
private var oAuthError: Throwable? = null
|
||||
private var oAuthCancelError: Throwable? = null
|
||||
private var loginError: Throwable? = null
|
||||
private var matrixClient: MatrixClient? = null
|
||||
private var onAuthenticationListener: ((MatrixClient) -> Unit)? = null
|
||||
@@ -70,18 +70,18 @@ class FakeMatrixAuthenticationService(
|
||||
return importCreatedSessionLambda(externalSession)
|
||||
}
|
||||
|
||||
override suspend fun getOidcUrl(
|
||||
prompt: OidcPrompt,
|
||||
override suspend fun getOAuthUrl(
|
||||
prompt: OAuthPrompt,
|
||||
loginHint: String?,
|
||||
): Result<OidcDetails> = simulateLongTask {
|
||||
oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
|
||||
): Result<OAuthDetails> = simulateLongTask {
|
||||
oAuthError?.let { Result.failure(it) } ?: Result.success(AN_OAUTH_DATA)
|
||||
}
|
||||
|
||||
override suspend fun cancelOidcLogin(): Result<Unit> {
|
||||
return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
override suspend fun cancelOAuthLogin(): Result<Unit> {
|
||||
return oAuthCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> = simulateLongTask {
|
||||
override suspend fun loginWithOAuth(callbackUrl: String): Result<SessionId> = simulateLongTask {
|
||||
loginError?.let { Result.failure(it) } ?: run {
|
||||
onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient())
|
||||
Result.success(A_USER_ID)
|
||||
@@ -97,12 +97,12 @@ class FakeMatrixAuthenticationService(
|
||||
onAuthenticationListener = lambda
|
||||
}
|
||||
|
||||
fun givenOidcError(throwable: Throwable?) {
|
||||
oidcError = throwable
|
||||
fun givenOAuthError(throwable: Throwable?) {
|
||||
oAuthError = throwable
|
||||
}
|
||||
|
||||
fun givenOidcCancelError(throwable: Throwable?) {
|
||||
oidcCancelError = throwable
|
||||
fun givenOAuthCancelError(throwable: Throwable?) {
|
||||
oAuthCancelError = throwable
|
||||
}
|
||||
|
||||
fun givenLoginError(throwable: Throwable?) {
|
||||
|
||||
+3
-3
@@ -8,12 +8,12 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test.auth
|
||||
|
||||
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthRedirectUrlProvider
|
||||
|
||||
const val FAKE_REDIRECT_URL = "io.element.android:/"
|
||||
|
||||
class FakeOidcRedirectUrlProvider(
|
||||
class FakeOAuthRedirectUrlProvider(
|
||||
private val provideResult: String = FAKE_REDIRECT_URL,
|
||||
) : OidcRedirectUrlProvider {
|
||||
) : OAuthRedirectUrlProvider {
|
||||
override fun provide() = provideResult
|
||||
}
|
||||
+2
-2
@@ -14,9 +14,9 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
fun aMatrixHomeServerDetails(
|
||||
url: String = A_HOMESERVER_URL,
|
||||
supportsPasswordLogin: Boolean = false,
|
||||
supportsOidcLogin: Boolean = false,
|
||||
supportsOAuthLogin: Boolean = false,
|
||||
) = MatrixHomeServerDetails(
|
||||
url = url,
|
||||
supportsPasswordLogin = supportsPasswordLogin,
|
||||
supportsOidcLogin = supportsOidcLogin,
|
||||
supportsOAuthLogin = supportsOAuthLogin,
|
||||
)
|
||||
|
||||
+6
-6
@@ -8,16 +8,16 @@
|
||||
|
||||
package io.element.android.libraries.matrix.test.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOAuthResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
|
||||
class FakeIdentityOidcResetHandle(
|
||||
class FakeIdentityOAuthResetHandle(
|
||||
override val url: String = "",
|
||||
var resetOidcLambda: () -> Result<Unit> = { error("Not implemented") },
|
||||
var resetOAuthLambda: () -> Result<Unit> = { error("Not implemented") },
|
||||
var cancelLambda: () -> Unit = { error("Not implemented") },
|
||||
) : IdentityOidcResetHandle {
|
||||
override suspend fun resetOidc(): Result<Unit> {
|
||||
return resetOidcLambda()
|
||||
) : IdentityOAuthResetHandle {
|
||||
override suspend fun resetOAuth(): Result<Unit> {
|
||||
return resetOAuthLambda()
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
|
||||
@@ -19,8 +19,10 @@ android {
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixmedia.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.gif)
|
||||
|
||||
+37
-3
@@ -12,8 +12,10 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.wysiwyg.utils.HtmlToDomParser
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Document.OutputSettings
|
||||
import org.jsoup.safety.Safelist
|
||||
|
||||
/**
|
||||
* Converts the HTML string [FormattedBody.body] to a [Document] by parsing it.
|
||||
@@ -34,9 +36,9 @@ fun FormattedBody.toHtmlDocument(
|
||||
?.trimEnd()
|
||||
?.let { formattedBody ->
|
||||
val dom = if (prefix != null) {
|
||||
HtmlToDomParser.document("$prefix $formattedBody")
|
||||
CustomHtmlToDomParser.document("$prefix $formattedBody")
|
||||
} else {
|
||||
HtmlToDomParser.document(formattedBody)
|
||||
CustomHtmlToDomParser.document(formattedBody)
|
||||
}
|
||||
|
||||
// Prepend `@` to mentions
|
||||
@@ -60,3 +62,35 @@ private fun fixMentions(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Custom Html to DOM parser, based on the one included in the rich text editor library. */
|
||||
private object CustomHtmlToDomParser {
|
||||
fun document(html: String): Document {
|
||||
val outputSettings = OutputSettings().prettyPrint(false).indentAmount(0)
|
||||
val cleanHtml = Jsoup.clean(html, "", safeList, outputSettings)
|
||||
return Jsoup.parse(cleanHtml)
|
||||
}
|
||||
|
||||
private val safeList = Safelist()
|
||||
.addTags(
|
||||
"a",
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"del",
|
||||
"code",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"p",
|
||||
"br",
|
||||
// Add custom `mx-reply` tag, even if it's just to remove its contents from the plain text version of the message
|
||||
"mx-reply"
|
||||
)
|
||||
.addAttributes("a", "href", "data-mention-type", "contenteditable")
|
||||
.addAttributes("ol", "start")
|
||||
}
|
||||
|
||||
+19
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.ui.messages
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
@@ -26,6 +27,19 @@ fun TextMessageType.toPlainText(
|
||||
permalinkParser: PermalinkParser,
|
||||
) = formatted?.toPlainText(permalinkParser) ?: body
|
||||
|
||||
/**
|
||||
* Converts the HTML string in [MessageTypeWithAttachment.formattedCaption] to a plain text representation by parsing it and removing all formatting.
|
||||
* If the caption is not formatted or the format is not [MessageFormat.HTML], the [MessageTypeWithAttachment.caption] is returned instead.
|
||||
* If there is no caption, returns [default].
|
||||
*/
|
||||
fun MessageTypeWithAttachment.toPlainText(
|
||||
permalinkParser: PermalinkParser,
|
||||
default: String = filename,
|
||||
): String {
|
||||
val plainTextFromFormatted = formattedCaption?.toPlainText(permalinkParser)
|
||||
return plainTextFromFormatted ?: caption ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting.
|
||||
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
|
||||
@@ -51,6 +65,8 @@ fun Document.toPlainText(): String {
|
||||
return visitor.build()
|
||||
}
|
||||
|
||||
private const val FALLBACK_REPLY_NODE_TAG = "mx-reply"
|
||||
|
||||
private class PlainTextNodeVisitor : NodeVisitor {
|
||||
private val builder = StringBuilder()
|
||||
|
||||
@@ -78,6 +94,9 @@ private class PlainTextNodeVisitor : NodeVisitor {
|
||||
} else {
|
||||
builder.append("• ")
|
||||
}
|
||||
} else if (node is Element && node.tagName() == FALLBACK_REPLY_NODE_TAG) {
|
||||
// Remove the fallback reply node and its contents so they aren't added to the plain text message
|
||||
node.remove()
|
||||
} else if (node is Element && node.isBlock && builder.lastOrNull() != '\n') {
|
||||
builder.append("\n")
|
||||
}
|
||||
|
||||
+15
@@ -136,4 +136,19 @@ class ToPlainTextTest {
|
||||
)
|
||||
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TextMessageType toPlainText - ignores mx-reply element`() {
|
||||
val messageType = TextMessageType(
|
||||
body = "This is the fallback text",
|
||||
formatted = FormattedBody(
|
||||
format = MessageFormat.HTML,
|
||||
body = """
|
||||
<mx-reply>In reply to...</mx-reply>
|
||||
This is the message content.
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the message content.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
api(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.tests.testutils)
|
||||
|
||||
+9
-4
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -27,10 +28,11 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
interface MediaGalleryDataSource {
|
||||
fun start()
|
||||
fun start(coroutineScope: CoroutineScope)
|
||||
fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>>
|
||||
fun getLastData(): AsyncData<GroupedMediaItems>
|
||||
suspend fun loadMore(direction: Timeline.PaginationDirection)
|
||||
@@ -58,7 +60,7 @@ class TimelineMediaGalleryDataSource(
|
||||
private val isStarted = AtomicBoolean(false)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun start() {
|
||||
override fun start(coroutineScope: CoroutineScope) {
|
||||
if (!isStarted.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
@@ -96,9 +98,12 @@ class TimelineMediaGalleryDataSource(
|
||||
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
|
||||
}
|
||||
.onCompletion {
|
||||
timeline?.close()
|
||||
timeline?.let {
|
||||
Timber.d("Timeline media gallery data source flow completed for room ${room.roomId}, closing timeline")
|
||||
it.close()
|
||||
}
|
||||
}
|
||||
.launchIn(room.roomCoroutineScope)
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
override suspend fun loadMore(direction: Timeline.PaginationDirection) {
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class MediaGalleryPresenter(
|
||||
.collectAsState(AsyncData.Uninitialized)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
mediaGalleryDataSource.start()
|
||||
mediaGalleryDataSource.start(this)
|
||||
}
|
||||
|
||||
val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms ->
|
||||
|
||||
+4
-2
@@ -35,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -62,11 +63,12 @@ class MediaViewerDataSource(
|
||||
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
|
||||
mutableMapOf()
|
||||
|
||||
fun setup() {
|
||||
galleryDataSource.start()
|
||||
fun setup(coroutineScope: CoroutineScope) {
|
||||
galleryDataSource.start(coroutineScope)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
Timber.d("Disposing MediaViewerDataSource, closing ${mediaFiles.size} media files")
|
||||
mediaFiles.forEach { it.close() }
|
||||
mediaFiles.clear()
|
||||
localMediaStates.clear()
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ class MediaViewerPresenter(
|
||||
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
dataSource.setup()
|
||||
dataSource.setup(coroutineScope)
|
||||
onDispose {
|
||||
dataSource.dispose()
|
||||
}
|
||||
|
||||
+2
-1
@@ -20,12 +20,13 @@ import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryData
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class SingleMediaGalleryDataSource(
|
||||
private val data: GroupedMediaItems,
|
||||
) : MediaGalleryDataSource {
|
||||
override fun start() = Unit
|
||||
override fun start(coroutineScope: CoroutineScope) = Unit
|
||||
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
|
||||
override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
|
||||
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
@@ -21,7 +22,7 @@ class FakeMediaGalleryDataSource(
|
||||
private val loadMoreLambda: (Timeline.PaginationDirection) -> Unit = { lambdaError() },
|
||||
private val deleteItemLambda: (EventId) -> Unit = { lambdaError() },
|
||||
) : MediaGalleryDataSource {
|
||||
override fun start() = startLambda()
|
||||
override fun start(coroutineScope: CoroutineScope) = startLambda()
|
||||
|
||||
private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(
|
||||
replay = 1
|
||||
|
||||
+6
-6
@@ -80,7 +80,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
roomCoroutineScope = backgroundScope,
|
||||
)
|
||||
)
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
assertThat(sut.getLastData()).isEqualTo(AsyncData.Uninitialized)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
@@ -95,7 +95,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
)
|
||||
assertThat(sut.getLastData().isSuccess()).isTrue()
|
||||
// Also test that starting again should have no effect
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
}
|
||||
}
|
||||
// Ensure that the timeline has been closed on flow completion
|
||||
@@ -117,7 +117,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
roomCoroutineScope = backgroundScope,
|
||||
)
|
||||
)
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
skipItems(2)
|
||||
sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
|
||||
@@ -140,7 +140,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
roomCoroutineScope = backgroundScope,
|
||||
)
|
||||
)
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
skipItems(2)
|
||||
sut.deleteItem(AN_EVENT_ID)
|
||||
@@ -159,7 +159,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
roomCoroutineScope = backgroundScope,
|
||||
)
|
||||
)
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
assertThat(sut.getLastData().isLoading()).isTrue()
|
||||
@@ -181,7 +181,7 @@ class TimelineMediaGalleryDataSourceTest {
|
||||
roomCoroutineScope = backgroundScope,
|
||||
)
|
||||
)
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
sut.groupedMediaItemsFlow().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
assertThat(sut.getLastData().isLoading()).isTrue()
|
||||
|
||||
+13
-15
@@ -6,12 +6,15 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.details
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -21,43 +24,38 @@ import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaDeleteConfirmationBottomSheetTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on Cancel invokes expected callback`() {
|
||||
fun `clicking on Cancel invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDeleteConfirmation()
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setMediaDeleteConfirmationBottomSheet(
|
||||
setMediaDeleteConfirmationBottomSheet(
|
||||
state = state,
|
||||
onDismiss = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
clickOn(CommonStrings.action_cancel)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on Remove invokes expected callback`() {
|
||||
fun `clicking on Remove invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDeleteConfirmation()
|
||||
ensureCalledOnceWithParam(state.eventId) { callback ->
|
||||
rule.setMediaDeleteConfirmationBottomSheet(
|
||||
setMediaDeleteConfirmationBottomSheet(
|
||||
state = state,
|
||||
onDelete = callback,
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertExists()
|
||||
rule.clickOn(CommonStrings.action_remove)
|
||||
onNodeWithText(activity!!.getString(CommonStrings.action_remove)).assertExists()
|
||||
clickOn(CommonStrings.action_remove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaDeleteConfirmationBottomSheet(
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMediaDeleteConfirmationBottomSheet(
|
||||
state: MediaBottomSheetState.DeleteConfirmation,
|
||||
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onDismiss: () -> Unit = EnsureNeverCalled(),
|
||||
|
||||
+25
-27
@@ -6,12 +6,15 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.details
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -20,97 +23,92 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaDetailsBottomSheetTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1024dp")
|
||||
fun `clicking on View in timeline invokes expected callback`() {
|
||||
fun `clicking on View in timeline invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDetails()
|
||||
ensureCalledOnceWithParam(state.eventId) { callback ->
|
||||
rule.setMediaDetailsBottomSheet(
|
||||
setMediaDetailsBottomSheet(
|
||||
state = state,
|
||||
onViewInTimeline = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_view_in_timeline)
|
||||
clickOn(CommonStrings.action_view_in_timeline)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1024dp")
|
||||
fun `clicking on Share invokes expected callback`() {
|
||||
fun `clicking on Share invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDetails()
|
||||
ensureCalledOnceWithParam(state.eventId) { callback ->
|
||||
rule.setMediaDetailsBottomSheet(
|
||||
setMediaDetailsBottomSheet(
|
||||
state = state,
|
||||
onShare = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_share)
|
||||
clickOn(CommonStrings.action_share)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1024dp")
|
||||
fun `clicking on Forward invokes expected callback`() {
|
||||
fun `clicking on Forward invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDetails()
|
||||
ensureCalledOnceWithParam(state.eventId) { callback ->
|
||||
rule.setMediaDetailsBottomSheet(
|
||||
setMediaDetailsBottomSheet(
|
||||
state = state,
|
||||
onForward = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_forward)
|
||||
clickOn(CommonStrings.action_forward)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h1024dp")
|
||||
fun `clicking on Download invokes expected callback`() {
|
||||
fun `clicking on Download invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDetails()
|
||||
ensureCalledOnceWithParam(state.eventId) { callback ->
|
||||
rule.setMediaDetailsBottomSheet(
|
||||
setMediaDetailsBottomSheet(
|
||||
state = state,
|
||||
onDownload = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_download)
|
||||
clickOn(CommonStrings.action_download)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on Delete invokes expected callback`() {
|
||||
fun `clicking on Delete invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDetails()
|
||||
ensureCalledOnceWithParam(state.eventId) { callback ->
|
||||
rule.setMediaDetailsBottomSheet(
|
||||
setMediaDetailsBottomSheet(
|
||||
state = state,
|
||||
onDelete = callback,
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_delete)).assertExists()
|
||||
rule.clickOn(CommonStrings.action_delete)
|
||||
onNodeWithText(activity!!.getString(CommonStrings.action_delete)).assertExists()
|
||||
clickOn(CommonStrings.action_delete)
|
||||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `Remove is not present if canDelete is false`() {
|
||||
fun `Remove is not present if canDelete is false`() = runAndroidComposeUiTest {
|
||||
val state = aMediaBottomSheetStateDetails(
|
||||
canDelete = false,
|
||||
)
|
||||
rule.setMediaDetailsBottomSheet(
|
||||
setMediaDetailsBottomSheet(
|
||||
state = state,
|
||||
)
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertDoesNotExist()
|
||||
onNodeWithText(activity!!.getString(CommonStrings.action_remove)).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaDetailsBottomSheet(
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMediaDetailsBottomSheet(
|
||||
state: MediaBottomSheetState.Details,
|
||||
onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class MediaViewerDataSourceTest {
|
||||
val sut = createMediaViewerDataSource(
|
||||
galleryDataSource = galleryDataSource,
|
||||
)
|
||||
sut.setup()
|
||||
sut.setup(backgroundScope)
|
||||
startLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
|
||||
+36
-36
@@ -6,18 +6,21 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeDown
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaBottomSheetStateDetails
|
||||
@@ -30,30 +33,26 @@ import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import io.mockk.mockk
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaViewerViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||
|
||||
@Test
|
||||
fun `clicking on back invokes expected callback`() {
|
||||
fun `clicking on back invokes expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
val state = aMediaViewerState(
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
state = state,
|
||||
onBackClick = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
pressBack()
|
||||
}
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
@@ -103,16 +102,16 @@ class MediaViewerViewTest {
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
@StringRes contentDescriptionRes: Int,
|
||||
expectedEvent: MediaViewerEvent,
|
||||
) {
|
||||
) = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
aMediaViewerState(
|
||||
listData = listOf(data),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
val contentDescription = rule.activity.getString(contentDescriptionRes)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
val contentDescription = activity!!.getString(contentDescriptionRes)
|
||||
onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
MediaViewerEvent.OnNavigateTo(0),
|
||||
@@ -159,16 +158,16 @@ class MediaViewerViewTest {
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
@StringRes textRes: Int,
|
||||
expectedEvent: MediaViewerEvent,
|
||||
) {
|
||||
) = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
aMediaViewerState(
|
||||
listData = listOf(data),
|
||||
mediaBottomSheetState = aMediaBottomSheetStateDetails(),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(textRes)
|
||||
clickOn(textRes)
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
MediaViewerEvent.OnNavigateTo(0),
|
||||
@@ -179,24 +178,25 @@ class MediaViewerViewTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on image hides the overlay`() {
|
||||
fun `clicking on image hides the overlay`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
val state = aMediaViewerState(
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
state = state,
|
||||
)
|
||||
// Ensure that the action are visible
|
||||
val contentDescription = rule.activity.getString(CommonStrings.action_share)
|
||||
rule.onNodeWithContentDescription(contentDescription)
|
||||
val resources = activity!!.resources
|
||||
val contentDescription = resources.getString(CommonStrings.action_share)
|
||||
onNodeWithContentDescription(contentDescription)
|
||||
.assertExists()
|
||||
.assertHasClickAction()
|
||||
val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
|
||||
rule.onNodeWithContentDescription(imageContentDescription).performClick()
|
||||
val imageContentDescription = resources.getString(CommonStrings.common_image)
|
||||
onNodeWithContentDescription(imageContentDescription).performClick()
|
||||
// Give time for the animation (? since even by removing AnimatedVisibility it still fails)
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
rule.onNodeWithContentDescription(contentDescription)
|
||||
mainClock.advanceTimeBy(1_000)
|
||||
onNodeWithContentDescription(contentDescription)
|
||||
.assertDoesNotExist()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
@@ -207,19 +207,19 @@ class MediaViewerViewTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking swipe on the image invokes the expected callback`() {
|
||||
fun `clicking swipe on the image invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
val state = aMediaViewerState(
|
||||
eventSink = eventsRecorder
|
||||
)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
state = state,
|
||||
onBackClick = callback,
|
||||
)
|
||||
val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
|
||||
rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
|
||||
rule.mainClock.advanceTimeBy(1_000)
|
||||
val imageContentDescription = activity!!.getString(CommonStrings.common_image)
|
||||
onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
|
||||
mainClock.advanceTimeBy(1_000)
|
||||
}
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
@@ -230,18 +230,18 @@ class MediaViewerViewTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error case, click on retry emits the expected Event`() {
|
||||
fun `error case, click on retry emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
val data = aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
|
||||
)
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
aMediaViewerState(
|
||||
listData = listOf(data),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_retry)
|
||||
clickOn(CommonStrings.action_retry)
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
MediaViewerEvent.OnNavigateTo(0),
|
||||
@@ -252,18 +252,18 @@ class MediaViewerViewTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error case, click on cancel emits the expected Event`() {
|
||||
fun `error case, click on cancel emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<MediaViewerEvent>()
|
||||
val data = aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
|
||||
)
|
||||
rule.setMediaViewerView(
|
||||
setMediaViewerView(
|
||||
aMediaViewerState(
|
||||
listData = listOf(data),
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
MediaViewerEvent.OnNavigateTo(0),
|
||||
@@ -274,7 +274,7 @@ class MediaViewerViewTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMediaViewerView(
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMediaViewerView(
|
||||
state: MediaViewerState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ class SingleMediaGalleryDataSourceTest {
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `function start is no op`() {
|
||||
fun `function start is no op`() = runTest {
|
||||
val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
|
||||
sut.start()
|
||||
sut.start(backgroundScope)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -18,6 +18,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.mediaviewer.impl)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
||||
@@ -11,7 +11,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.oidc.api"
|
||||
namespace = "io.element.android.libraries.oauth.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
+4
-4
@@ -6,9 +6,9 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.api
|
||||
package io.element.android.libraries.oauth.api
|
||||
|
||||
sealed interface OidcAction {
|
||||
data class GoBack(val toUnblock: Boolean = false) : OidcAction
|
||||
data class Success(val url: String) : OidcAction
|
||||
sealed interface OAuthAction {
|
||||
data class GoBack(val toUnblock: Boolean = false) : OAuthAction
|
||||
data class Success(val url: String) : OAuthAction
|
||||
}
|
||||
+4
-4
@@ -6,12 +6,12 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.api
|
||||
package io.element.android.libraries.oauth.api
|
||||
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
||||
interface OidcActionFlow {
|
||||
fun post(oidcAction: OidcAction)
|
||||
suspend fun collect(collector: FlowCollector<OidcAction?>)
|
||||
interface OAuthActionFlow {
|
||||
fun post(oAuthAction: OAuthAction)
|
||||
suspend fun collect(collector: FlowCollector<OAuthAction?>)
|
||||
fun reset()
|
||||
}
|
||||
+3
-3
@@ -6,10 +6,10 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.api
|
||||
package io.element.android.libraries.oauth.api
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
interface OidcIntentResolver {
|
||||
fun resolve(intent: Intent): OidcAction?
|
||||
interface OAuthIntentResolver {
|
||||
fun resolve(intent: Intent): OAuthAction?
|
||||
}
|
||||
@@ -16,7 +16,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.oidc.impl"
|
||||
namespace = "io.element.android.libraries.oauth.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
@@ -39,7 +39,7 @@ dependencies {
|
||||
implementation(platform(libs.network.retrofit.bom))
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.serialization.json)
|
||||
api(projects.libraries.oidc.api)
|
||||
api(projects.libraries.oauth.api)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
+8
-8
@@ -6,26 +6,26 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
package io.element.android.libraries.oauth.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultOidcActionFlow : OidcActionFlow {
|
||||
private val mutableStateFlow = MutableStateFlow<OidcAction?>(null)
|
||||
class DefaultOAuthActionFlow : OAuthActionFlow {
|
||||
private val mutableStateFlow = MutableStateFlow<OAuthAction?>(null)
|
||||
|
||||
override fun post(oidcAction: OidcAction) {
|
||||
mutableStateFlow.value = oidcAction
|
||||
override fun post(oAuthAction: OAuthAction) {
|
||||
mutableStateFlow.value = oAuthAction
|
||||
}
|
||||
|
||||
override suspend fun collect(collector: FlowCollector<OidcAction?>) {
|
||||
override suspend fun collect(collector: FlowCollector<OAuthAction?>) {
|
||||
mutableStateFlow.collect(collector)
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oauth.impl
|
||||
|
||||
import android.content.Intent
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthIntentResolver
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOAuthIntentResolver(
|
||||
private val oAuthUrlParser: OAuthUrlParser,
|
||||
) : OAuthIntentResolver {
|
||||
override fun resolve(intent: Intent): OAuthAction? {
|
||||
return oAuthUrlParser.parse(intent.dataString.orEmpty())
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -6,37 +6,37 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
package io.element.android.libraries.oauth.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthRedirectUrlProvider
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
|
||||
fun interface OidcUrlParser {
|
||||
fun parse(url: String): OidcAction?
|
||||
fun interface OAuthUrlParser {
|
||||
fun parse(url: String): OAuthAction?
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple parser for oidc url interception.
|
||||
* Simple parser for OAuth url interception.
|
||||
* TODO Find documentation about the format.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOidcUrlParser(
|
||||
private val oidcRedirectUrlProvider: OidcRedirectUrlProvider,
|
||||
) : OidcUrlParser {
|
||||
class DefaultOAuthUrlParser(
|
||||
private val oAuthRedirectUrlProvider: OAuthRedirectUrlProvider,
|
||||
) : OAuthUrlParser {
|
||||
/**
|
||||
* Return a OidcAction, or null if the url is not a OidcUrl.
|
||||
* Return a [OAuthAction], or null if the url is not an OAuth url.
|
||||
* Note:
|
||||
* When user press button "Cancel", we get the url:
|
||||
* `io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO`
|
||||
* On success, we get:
|
||||
* `io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
|
||||
*/
|
||||
override fun parse(url: String): OidcAction? {
|
||||
if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null
|
||||
if (url.contains("error=access_denied")) return OidcAction.GoBack()
|
||||
if (url.contains("code=")) return OidcAction.Success(url)
|
||||
override fun parse(url: String): OAuthAction? {
|
||||
if (url.startsWith(oAuthRedirectUrlProvider.provide()).not()) return null
|
||||
if (url.contains("error=access_denied")) return OAuthAction.GoBack()
|
||||
if (url.contains("code=")) return OAuthAction.Success(url)
|
||||
|
||||
// Other case not supported, let's crash the app for now
|
||||
error("Not supported: $url")
|
||||
+8
-9
@@ -1,34 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
package io.element.android.libraries.oauth.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultOidcActionFlowTest {
|
||||
class DefaultOAuthActionFlowTest {
|
||||
@Test
|
||||
fun `collect gets all the posted events`() = runTest {
|
||||
val data = mutableListOf<OidcAction?>()
|
||||
val sut = DefaultOidcActionFlow()
|
||||
val data = mutableListOf<OAuthAction?>()
|
||||
val sut = DefaultOAuthActionFlow()
|
||||
backgroundScope.launch {
|
||||
sut.collect { action ->
|
||||
data.add(action)
|
||||
}
|
||||
}
|
||||
sut.post(OidcAction.GoBack())
|
||||
sut.post(OAuthAction.GoBack())
|
||||
delay(1)
|
||||
sut.reset()
|
||||
delay(1)
|
||||
assertThat(data).containsExactly(OidcAction.GoBack(), null)
|
||||
assertThat(data).containsExactly(OAuthAction.GoBack(), null)
|
||||
}
|
||||
}
|
||||
+17
-18
@@ -1,19 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
package io.element.android.libraries.oauth.impl
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -21,36 +20,36 @@ import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DefaultOidcIntentResolverTest {
|
||||
class DefaultOAuthIntentResolverTest {
|
||||
@Test
|
||||
fun `test resolve oidc go back`() {
|
||||
val sut = createDefaultOidcIntentResolver()
|
||||
fun `test resolve OAuth go back`() {
|
||||
val sut = createDefaultOAuthIntentResolver()
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
|
||||
}
|
||||
val result = sut.resolve(intent)
|
||||
assertThat(result).isEqualTo(OidcAction.GoBack())
|
||||
assertThat(result).isEqualTo(OAuthAction.GoBack())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test resolve oidc success`() {
|
||||
val sut = createDefaultOidcIntentResolver()
|
||||
fun `test resolve OAuth success`() {
|
||||
val sut = createDefaultOAuthIntentResolver()
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = "io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB".toUri()
|
||||
}
|
||||
val result = sut.resolve(intent)
|
||||
assertThat(result).isEqualTo(
|
||||
OidcAction.Success(
|
||||
OAuthAction.Success(
|
||||
url = "io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test resolve oidc invalid`() {
|
||||
val sut = createDefaultOidcIntentResolver()
|
||||
fun `test resolve OAuth invalid`() {
|
||||
val sut = createDefaultOAuthIntentResolver()
|
||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = "io.element.android:/invalid".toUri()
|
||||
@@ -60,10 +59,10 @@ class DefaultOidcIntentResolverTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDefaultOidcIntentResolver(): DefaultOidcIntentResolver {
|
||||
return DefaultOidcIntentResolver(
|
||||
oidcUrlParser = DefaultOidcUrlParser(
|
||||
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
|
||||
private fun createDefaultOAuthIntentResolver(): DefaultOAuthIntentResolver {
|
||||
return DefaultOAuthIntentResolver(
|
||||
oAuthUrlParser = DefaultOAuthUrlParser(
|
||||
oAuthRedirectUrlProvider = FakeOAuthRedirectUrlProvider(),
|
||||
),
|
||||
)
|
||||
}
|
||||
+15
-16
@@ -1,59 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
package io.element.android.libraries.oauth.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.auth.FAKE_REDIRECT_URL
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultOidcUrlParserTest {
|
||||
class DefaultOAuthUrlParserTest {
|
||||
@Test
|
||||
fun `test empty url`() {
|
||||
val sut = createDefaultOidcUrlParser()
|
||||
val sut = createDefaultOAuthUrlParser()
|
||||
assertThat(sut.parse("")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test regular url`() {
|
||||
val sut = createDefaultOidcUrlParser()
|
||||
val sut = createDefaultOAuthUrlParser()
|
||||
assertThat(sut.parse("https://matrix.org")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cancel url`() {
|
||||
val sut = createDefaultOidcUrlParser()
|
||||
val sut = createDefaultOAuthUrlParser()
|
||||
val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO"
|
||||
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack())
|
||||
assertThat(sut.parse(aCancelUrl)).isEqualTo(OAuthAction.GoBack())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test success url`() {
|
||||
val sut = createDefaultOidcUrlParser()
|
||||
val sut = createDefaultOAuthUrlParser()
|
||||
val aSuccessUrl = "$FAKE_REDIRECT_URL?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl))
|
||||
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OAuthAction.Success(aSuccessUrl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test unknown url`() {
|
||||
val sut = createDefaultOidcUrlParser()
|
||||
val sut = createDefaultOAuthUrlParser()
|
||||
val anUnknownUrl = "$FAKE_REDIRECT_URL?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||
Assert.assertThrows(IllegalStateException::class.java) {
|
||||
assertThat(sut.parse(anUnknownUrl))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDefaultOidcUrlParser(): DefaultOidcUrlParser {
|
||||
return DefaultOidcUrlParser(
|
||||
oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(),
|
||||
private fun createDefaultOAuthUrlParser(): DefaultOAuthUrlParser {
|
||||
return DefaultOAuthUrlParser(
|
||||
oAuthRedirectUrlProvider = FakeOAuthRedirectUrlProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.oidc.test"
|
||||
namespace = "io.element.android.libraries.oauth.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
api(projects.libraries.oidc.api)
|
||||
api(projects.libraries.oauth.api)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
+7
-7
@@ -6,17 +6,17 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.test
|
||||
package io.element.android.libraries.oauth.test
|
||||
|
||||
import android.content.Intent
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcIntentResolver
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthIntentResolver
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeOidcIntentResolver(
|
||||
private val resolveResult: (Intent) -> OidcAction? = { lambdaError() }
|
||||
) : OidcIntentResolver {
|
||||
override fun resolve(intent: Intent): OidcAction? {
|
||||
class FakeOAuthIntentResolver(
|
||||
private val resolveResult: (Intent) -> OAuthAction? = { lambdaError() }
|
||||
) : OAuthIntentResolver {
|
||||
override fun resolve(intent: Intent): OAuthAction? {
|
||||
return resolveResult(intent)
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oauth.test.customtab
|
||||
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* This is actually a copy of DefaultOAuthActionFlow.
|
||||
*/
|
||||
class FakeOAuthActionFlow : OAuthActionFlow {
|
||||
private val mutableStateFlow = MutableStateFlow<OAuthAction?>(null)
|
||||
|
||||
override fun post(oAuthAction: OAuthAction) {
|
||||
mutableStateFlow.value = oAuthAction
|
||||
}
|
||||
|
||||
override suspend fun collect(collector: FlowCollector<OAuthAction?>) {
|
||||
mutableStateFlow.collect(collector)
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
mutableStateFlow.value = null
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
|
||||
import android.content.Intent
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcIntentResolver
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOidcIntentResolver(
|
||||
private val oidcUrlParser: OidcUrlParser,
|
||||
) : OidcIntentResolver {
|
||||
override fun resolve(intent: Intent): OidcAction? {
|
||||
return oidcUrlParser.parse(intent.dataString.orEmpty())
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.test.customtab
|
||||
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* This is actually a copy of DefaultOidcActionFlow.
|
||||
*/
|
||||
class FakeOidcActionFlow : OidcActionFlow {
|
||||
private val mutableStateFlow = MutableStateFlow<OidcAction?>(null)
|
||||
|
||||
override fun post(oidcAction: OidcAction) {
|
||||
mutableStateFlow.value = oidcAction
|
||||
}
|
||||
|
||||
override suspend fun collect(collector: FlowCollector<OidcAction?>) {
|
||||
mutableStateFlow.collect(collector)
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
mutableStateFlow.value = null
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,5 @@ dependencies {
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
}
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ package io.element.android.libraries.push.impl.notifications
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.CallData
|
||||
import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -215,9 +215,9 @@ class DefaultNotificationResultProcessor(
|
||||
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
|
||||
Timber.i("## handleInternal() : Incoming call.")
|
||||
elementCallEntryPoint.handleIncomingCall(
|
||||
callType = CallType.RoomCall(
|
||||
notifiableEvent.sessionId,
|
||||
notifiableEvent.roomId,
|
||||
callData = CallData(
|
||||
sessionId = notifiableEvent.sessionId,
|
||||
roomId = notifiableEvent.roomId,
|
||||
isAudioCall = notifiableEvent.callIntent == CallIntent.AUDIO
|
||||
),
|
||||
eventId = notifiableEvent.eventId,
|
||||
|
||||
+6
-3
@@ -11,6 +11,7 @@ package io.element.android.libraries.push.impl.push
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
@@ -35,6 +36,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
||||
@@ -53,6 +55,7 @@ class DefaultPushHandler(
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val syncPendingNotificationsRequestFactory: SyncPendingNotificationsRequestBuilder.Factory,
|
||||
resultProcessor: NotificationResultProcessor,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : PushHandler {
|
||||
init {
|
||||
resultProcessor.start()
|
||||
@@ -64,7 +67,7 @@ class DefaultPushHandler(
|
||||
* @param pushData the data received in the push.
|
||||
* @param providerInfo the provider info.
|
||||
*/
|
||||
override suspend fun handle(pushData: PushData, providerInfo: String): Boolean {
|
||||
override suspend fun handle(pushData: PushData, providerInfo: String): Boolean = withContext(dispatchers.computation) {
|
||||
// Start measuring how long it takes to display a notification from when the push is received
|
||||
Timber.d("Calculating push-to-notification for event ${pushData.eventId}")
|
||||
val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value))
|
||||
@@ -81,7 +84,7 @@ class DefaultPushHandler(
|
||||
}
|
||||
|
||||
// Diagnostic Push
|
||||
return if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) {
|
||||
if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) {
|
||||
pushHistoryService.onDiagnosticPush(providerInfo)
|
||||
diagnosticPushHandler.handlePush()
|
||||
false
|
||||
@@ -90,7 +93,7 @@ class DefaultPushHandler(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun handleInvalid(providerInfo: String, data: String) {
|
||||
override suspend fun handleInvalid(providerInfo: String, data: String) = withContext(dispatchers.computation) {
|
||||
incrementPushDataStore.incrementPushCounter()
|
||||
pushHistoryService.onInvalidPushReceived(providerInfo, data)
|
||||
}
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.api.CallData
|
||||
import io.element.android.features.call.test.FakeElementCallEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -104,7 +104,7 @@ class DefaultNotificationResultProcessorTest {
|
||||
@Test
|
||||
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
CallData,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
@@ -140,7 +140,7 @@ class DefaultNotificationResultProcessorTest {
|
||||
fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest {
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
CallData,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
@@ -176,7 +176,7 @@ class DefaultNotificationResultProcessorTest {
|
||||
fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest {
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val handleIncomingCallLambda = lambdaRecorder<
|
||||
CallType.RoomCall,
|
||||
CallData,
|
||||
EventId,
|
||||
UserId,
|
||||
String?,
|
||||
|
||||
+7
-2
@@ -11,6 +11,7 @@
|
||||
package io.element.android.libraries.push.impl.push
|
||||
|
||||
import app.cash.turbine.test
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -40,7 +41,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -212,7 +215,7 @@ class DefaultPushHandlerTest {
|
||||
.isCalledOnce()
|
||||
}
|
||||
|
||||
private fun createDefaultPushHandler(
|
||||
private fun TestScope.createDefaultPushHandler(
|
||||
incrementPushCounterResult: () -> Unit = { lambdaError() },
|
||||
userPushStore: FakeUserPushStore = FakeUserPushStore(),
|
||||
pushClientSecret: PushClientSecret = FakePushClientSecret(),
|
||||
@@ -227,6 +230,7 @@ class DefaultPushHandlerTest {
|
||||
start = {},
|
||||
stop = {},
|
||||
),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
): DefaultPushHandler {
|
||||
return DefaultPushHandler(
|
||||
incrementPushDataStore = object : IncrementPushDataStore {
|
||||
@@ -246,7 +250,8 @@ class DefaultPushHandlerTest {
|
||||
resultProcessor = resultProcessor,
|
||||
syncPendingNotificationsRequestFactory = SyncPendingNotificationsRequestBuilder.Factory {
|
||||
FakeSyncPendingNotificationsRequestBuilder()
|
||||
}
|
||||
},
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ dependencies {
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.troubleshoot.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
||||
@@ -21,6 +21,7 @@ setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.recentemojis.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@ data class SessionData(
|
||||
val refreshToken: String?,
|
||||
/** The homeserver URL of the session. */
|
||||
val homeserverUrl: String,
|
||||
/** The Open ID Connect info for this session, if any. */
|
||||
val oidcData: String?,
|
||||
/** The Open Authorization info for this session, if any. */
|
||||
val oAuthData: String?,
|
||||
/** The timestamp of the last login. May be `null` in very old sessions. */
|
||||
val loginTimestamp: Date?,
|
||||
/** Whether the [accessToken] is valid or not. */
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
oidcData = oidcData,
|
||||
oidcData = oAuthData,
|
||||
loginTimestamp = loginTimestamp?.time,
|
||||
isTokenValid = if (isTokenValid) 1L else 0L,
|
||||
loginType = loginType.name,
|
||||
@@ -41,7 +41,7 @@ internal fun DbSessionData.toApiModel(): SessionData {
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = homeserverUrl,
|
||||
oidcData = oidcData,
|
||||
oAuthData = oidcData,
|
||||
loginTimestamp = loginTimestamp?.let { Date(it) },
|
||||
isTokenValid = isTokenValid == 1L,
|
||||
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
|
||||
|
||||
@@ -14,6 +14,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ fun aSessionData(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
homeserverUrl = "aHomeserverUrl",
|
||||
oidcData = null,
|
||||
oAuthData = null,
|
||||
loginTimestamp = null,
|
||||
isTokenValid = isTokenValid,
|
||||
loginType = LoginType.UNKNOWN,
|
||||
|
||||
+54
-73
@@ -6,12 +6,15 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.textcomposer.impl.components.markdown
|
||||
|
||||
import android.widget.EditText
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
@@ -32,66 +35,54 @@ import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState
|
||||
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MarkdownTextInputTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `when user types onTyping is triggered with value 'true'`() = runTest {
|
||||
fun `when user types onTyping is triggered with value 'true'`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit)
|
||||
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
it.findEditor().setText("Test")
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state, onTyping = onTyping)
|
||||
activity!!.findEditor().setText("Test")
|
||||
awaitIdle()
|
||||
onTyping.assertSuccess()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user removes text onTyping is triggered with value 'false'`() = runTest {
|
||||
fun `when user removes text onTyping is triggered with value 'false'`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onTyping = EventsRecorder<Boolean>()
|
||||
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
val editText = it.findEditor()
|
||||
editText.setText("Test")
|
||||
editText.setText("")
|
||||
editText.setText(null)
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state, onTyping = onTyping)
|
||||
val editText = activity!!.findEditor()
|
||||
editText.setText("Test")
|
||||
editText.setText("")
|
||||
editText.setText(null)
|
||||
awaitIdle()
|
||||
onTyping.assertList(listOf(true, false, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest {
|
||||
fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onSuggestionReceived = EventsRecorder<Suggestion?>()
|
||||
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
it.findEditor().setText("Test")
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
|
||||
activity!!.findEditor().setText("Test")
|
||||
awaitIdle()
|
||||
onSuggestionReceived.assertSingle(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest {
|
||||
fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialFocus = true)
|
||||
val onSuggestionReceived = EventsRecorder<Suggestion?>()
|
||||
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
it.findEditor().setText("@")
|
||||
it.findEditor().setText("#")
|
||||
it.findEditor().setText("/")
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
|
||||
val editor = activity!!.findEditor()
|
||||
editor.setText("@")
|
||||
editor.setText("#")
|
||||
editor.setText("/")
|
||||
awaitIdle()
|
||||
onSuggestionReceived.assertList(
|
||||
listOf(
|
||||
// User mention suggestion
|
||||
@@ -105,69 +96,59 @@ class MarkdownTextInputTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the selection changes in the UI the state is updated`() = runTest {
|
||||
fun `when the selection changes in the UI the state is updated`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
val editor = it.findEditor()
|
||||
editor.setSelection(2)
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state)
|
||||
val editor = activity!!.findEditor()
|
||||
editor.setSelection(2)
|
||||
awaitIdle()
|
||||
// Selection is updated
|
||||
assertThat(state.selection).isEqualTo(2..2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the selection state changes in the view is updated`() = runTest {
|
||||
fun `when the selection state changes in the view is updated`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
var editor: EditText? = null
|
||||
rule.activityRule.scenario.onActivity {
|
||||
editor = it.findEditor()
|
||||
state.selection = 2..2
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state)
|
||||
val editor = activity!!.findEditor()
|
||||
state.selection = 2..2
|
||||
awaitIdle()
|
||||
// Selection state is updated
|
||||
assertThat(editor?.selectionStart).isEqualTo(2)
|
||||
assertThat(editor?.selectionEnd).isEqualTo(2)
|
||||
assertThat(editor.selectionStart).isEqualTo(2)
|
||||
assertThat(editor.selectionEnd).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the view focus changes the state is updated`() = runTest {
|
||||
fun `when the view focus changes the state is updated`() = runAndroidComposeUiTest {
|
||||
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false)
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
rule.activityRule.scenario.onActivity {
|
||||
val editor = it.findEditor()
|
||||
editor.requestFocus()
|
||||
}
|
||||
setMarkdownTextInput(state = state)
|
||||
val editor = activity!!.findEditor()
|
||||
editor.requestFocus()
|
||||
// Focus state is updated
|
||||
assertThat(state.hasFocus).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inserting a mention replaces the existing text with a span`() = runTest {
|
||||
fun `inserting a mention replaces the existing text with a span`() = runAndroidComposeUiTest {
|
||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
|
||||
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
|
||||
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
|
||||
rule.setMarkdownTextInput(state = state)
|
||||
var editor: EditText? = null
|
||||
rule.activityRule.scenario.onActivity {
|
||||
editor = it.findEditor()
|
||||
state.insertSuggestion(
|
||||
ResolvedSuggestion.Member(roomMember = aRoomMember()),
|
||||
aMentionSpanProvider(permalinkParser),
|
||||
)
|
||||
}
|
||||
rule.awaitIdle()
|
||||
setMarkdownTextInput(state = state)
|
||||
val editor = activity!!.findEditor()
|
||||
state.insertSuggestion(
|
||||
ResolvedSuggestion.Member(roomMember = aRoomMember()),
|
||||
aMentionSpanProvider(permalinkParser),
|
||||
)
|
||||
awaitIdle()
|
||||
|
||||
// Text is replaced with a placeholder
|
||||
assertThat(editor?.editableText.toString()).isEqualTo("@ ")
|
||||
assertThat(editor.editableText.toString()).isEqualTo("@ ")
|
||||
// The placeholder contains a MentionSpan
|
||||
val mentionSpans = editor?.editableText?.getSpans<MentionSpan>(0, 2).orEmpty()
|
||||
val mentionSpans = editor.editableText?.getSpans<MentionSpan>(0, 2).orEmpty()
|
||||
assertThat(mentionSpans).isNotEmpty()
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setMarkdownTextInput(
|
||||
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
||||
onTyping: (Boolean) -> Unit = {},
|
||||
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
||||
|
||||
+18
-20
@@ -6,60 +6,58 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.troubleshoot.impl
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TroubleshootNotificationsViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `press menu back invokes the expected callback`() {
|
||||
fun `press menu back invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
rule.setTroubleshootNotificationsView(
|
||||
setTroubleshootNotificationsView(
|
||||
state = aTroubleshootNotificationsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
onBackClick = it,
|
||||
)
|
||||
rule.pressBack()
|
||||
pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on run test emits the expected Event`() {
|
||||
fun `clicking on run test emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>()
|
||||
rule.setTroubleshootNotificationsView(
|
||||
setTroubleshootNotificationsView(
|
||||
aTroubleshootNotificationsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Run tests").performClick()
|
||||
onNodeWithText("Run tests").performClick()
|
||||
eventsRecorder.assertSingle(TroubleshootNotificationsEvents.StartTests)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on run test again emits the expected Event`() {
|
||||
fun `clicking on run test again emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>()
|
||||
rule.setTroubleshootNotificationsView(
|
||||
setTroubleshootNotificationsView(
|
||||
aTroubleshootNotificationsState(
|
||||
tests = listOf(
|
||||
aTroubleshootTestStateFailure(
|
||||
@@ -69,7 +67,7 @@ class TroubleshootNotificationsViewTest {
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Run tests again").performClick()
|
||||
onNodeWithText("Run tests again").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
TroubleshootNotificationsEvents.RetryFailedTests,
|
||||
@@ -80,9 +78,9 @@ class TroubleshootNotificationsViewTest {
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on quick fix emits the expected Event`() {
|
||||
fun `clicking on quick fix emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>()
|
||||
rule.setTroubleshootNotificationsView(
|
||||
setTroubleshootNotificationsView(
|
||||
aTroubleshootNotificationsState(
|
||||
tests = listOf(
|
||||
aTroubleshootTestStateFailure(
|
||||
@@ -92,7 +90,7 @@ class TroubleshootNotificationsViewTest {
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Attempt to fix").performClick()
|
||||
onNodeWithText("Attempt to fix").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
TroubleshootNotificationsEvents.RetryFailedTests,
|
||||
@@ -102,7 +100,7 @@ class TroubleshootNotificationsViewTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTroubleshootNotificationsView(
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setTroubleshootNotificationsView(
|
||||
state: TroubleshootNotificationsState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
|
||||
+28
-30
@@ -6,14 +6,17 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.troubleshoot.impl.history
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_FORMATTED_DATE
|
||||
@@ -23,67 +26,62 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PushHistoryViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on Reset sends a PushHistoryEvents`() {
|
||||
fun `clicking on Reset sends a PushHistoryEvents`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
rule.setPushHistoryView(
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
pushCounter = 123,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
|
||||
rule.onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
rule.clickOn(CommonStrings.action_reset)
|
||||
val menuContentDescription = activity!!.getString(CommonStrings.a11y_user_menu)
|
||||
onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
clickOn(CommonStrings.action_reset)
|
||||
eventsRecorder.assertSingle(PushHistoryEvents.Reset(requiresConfirmation = true))
|
||||
// Also check that the push counter is rendered
|
||||
rule.onNodeWithText("123").assertExists()
|
||||
onNodeWithText("123").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on show only errors sends a PushHistoryEvents(true)`() {
|
||||
fun `clicking on show only errors sends a PushHistoryEvents(true)`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
rule.setPushHistoryView(
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
showOnlyErrors = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
|
||||
rule.onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
rule.onNodeWithText("Show only errors").performClick()
|
||||
val menuContentDescription = activity!!.getString(CommonStrings.a11y_user_menu)
|
||||
onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
onNodeWithText("Show only errors").performClick()
|
||||
eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on show only errors sends a PushHistoryEvents(false)`() {
|
||||
fun `clicking on show only errors sends a PushHistoryEvents(false)`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
rule.setPushHistoryView(
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
showOnlyErrors = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
|
||||
rule.onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
rule.onNodeWithText("Show only errors").performClick()
|
||||
val menuContentDescription = activity!!.getString(CommonStrings.a11y_user_menu)
|
||||
onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
onNodeWithText("Show only errors").performClick()
|
||||
eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on an invalid event has no effect`() {
|
||||
fun `clicking on an invalid event has no effect`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
|
||||
rule.setPushHistoryView(
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
pushHistoryItems = listOf(
|
||||
aPushHistoryItem(
|
||||
@@ -93,14 +91,14 @@ class PushHistoryViewTest {
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
|
||||
onNodeWithText(A_FORMATTED_DATE).performClick()
|
||||
// No callback invoked
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a valid event emits the expected Event`() {
|
||||
fun `clicking on a valid event emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
rule.setPushHistoryView(
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
pushHistoryItems = listOf(
|
||||
aPushHistoryItem(
|
||||
@@ -113,7 +111,7 @@ class PushHistoryViewTest {
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
|
||||
onNodeWithText(A_FORMATTED_DATE).performClick()
|
||||
eventsRecorder.assertSingle(
|
||||
PushHistoryEvents.NavigateTo(
|
||||
sessionId = A_SESSION_ID,
|
||||
@@ -124,7 +122,7 @@ class PushHistoryViewTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPushHistoryView(
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setPushHistoryView(
|
||||
state: PushHistoryState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
|
||||
@@ -16,5 +16,6 @@ android {
|
||||
dependencies {
|
||||
implementation(libs.androidx.annotationjvm)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ setupDependencyInjection()
|
||||
dependencies {
|
||||
api(projects.libraries.voiceplayer.api)
|
||||
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.audio.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
|
||||
@@ -15,6 +15,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
api(libs.androidx.workmanager.runtime)
|
||||
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user