Merge branch 'release/25.05.2'

This commit is contained in:
Jorge Martín 2025-05-12 16:43:35 +02:00
commit 30a49216f7
94 changed files with 1497 additions and 1171 deletions

View file

@ -1,3 +1,18 @@
Changes in Element X v25.05.1
=============================
## What's Changed
### 🐛 Bugfixes
* Fix broken Element Call in 25.05.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/4694
### Dependency upgrades
* fix(deps): update android.gradle.plugin to v8.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4687
* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4696
### Others
* Let EnterpriseService prevent usage of homeserver by @bmarty in https://github.com/element-hq/element-x-android/pull/4682
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.0...v25.05.1
Changes in Element X v25.05.0
=============================

View file

@ -9,6 +9,7 @@ package io.element.android.appconfig
object LearnMoreConfig {
const val ENCRYPTION_URL: String = "https://element.io/help#encryption"
const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
}

View file

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

View file

@ -1,5 +1,4 @@
import extension.setupAnvil
import org.gradle.kotlin.dsl.test
/*
* Copyright 2023, 2024 New Vector Ltd.

View file

@ -26,7 +26,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
@ -40,7 +40,7 @@ import kotlinx.parcelize.Parcelize
class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
backstack = BackStack(
@ -94,19 +94,19 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
learnMoreUrl.value = LearnMoreConfig.DEVICE_VERIFICATION_URL
}
}
createNode<ChooseSelfVerificationModeNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.UseAnotherDevice -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(VerifySessionEntryPoint.Params(
outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.CurrentSession,
))
.callback(object : VerifySessionEntryPoint.Callback {
.callback(object : OutgoingVerificationEntryPoint.Callback {
override fun onDone() {
plugins<Callback>().forEach { it.onDone() }
}
@ -116,7 +116,8 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
}
override fun onLearnMoreAboutEncryption() {
learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL
// Note that this callback is never called. The "Learn more" link is not displayed
// for the self session interactive verification.
}
})
.build()

View file

@ -28,7 +28,7 @@ data class LoginPasswordState(
@Parcelize
data class LoginFormState(
val login: String,
val password: String
val password: String,
) : Parcelable {
companion object {
val Default = LoginFormState("", "")

View file

@ -8,23 +8,38 @@
package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.SessionId
open class LoginPasswordStateProvider : PreviewParameterProvider<LoginPasswordState> {
override val values: Sequence<LoginPasswordState>
get() = sequenceOf(
aLoginPasswordState(),
// Loading
aLoginPasswordState().copy(loginAction = AsyncData.Loading()),
aLoginPasswordState(loginAction = AsyncData.Loading()),
// Error
aLoginPasswordState().copy(loginAction = AsyncData.Failure(Exception("An error occurred"))),
aLoginPasswordState(loginAction = AsyncData.Failure(Exception("An error occurred"))),
)
}
fun aLoginPasswordState() = LoginPasswordState(
accountProvider = anAccountProvider(),
formState = LoginFormState.Default,
loginAction = AsyncData.Uninitialized,
eventSink = {}
fun aLoginPasswordState(
accountProvider: AccountProvider = anAccountProvider(),
formState: LoginFormState = LoginFormState.Default,
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized,
eventSink: (LoginPasswordEvents) -> Unit = {},
) = LoginPasswordState(
accountProvider = accountProvider,
formState = formState,
loginAction = loginAction,
eventSink = eventSink,
)
fun aLoginFormState(
login: String = "",
password: String = "",
) = LoginFormState(
login = login,
password = password,
)

View file

@ -201,6 +201,7 @@ private fun LoginForm(
{
Box(Modifier.clickable {
loginFieldState = ""
eventSink(LoginPasswordEvents.SetLogin(""))
}) {
Icon(
imageVector = CompoundIcons.Close(),

View file

@ -14,7 +14,7 @@
<string name="screen_change_account_provider_subtitle">"Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet."</string>
<string name="screen_change_account_provider_title">"Změnit poskytovatele účtu"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_invalid_well_known">"Klouzavá synchronizace není k dispozici kvůli problému se souborem well-known:
<string name="screen_change_server_error_invalid_well_known">"Server není k dispozici kvůli problému se souborem well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Vybraný poskytovatel účtu nepodporuje klouzavou synchronizaci. Pro použití %1$s je nutná aktualizace serveru."</string>
<string name="screen_change_server_form_header">"Adresa URL domovského serveru"</string>

View file

@ -17,6 +17,7 @@
<string name="screen_change_server_error_invalid_well_known">"Der Server ist aufgrund eines Problems im \"well-known file\" nicht verfügbar:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Der gewählte Kontoanbieter unterstützt Sliding Sync nicht. Für die Verwendung von %1$s ist ein Upgrade des Servers erforderlich."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$sdarf keine Verbindung herstellen zu%2$s."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Geben Sie eine Domainadresse ein."</string>
<string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string>

View file

@ -14,7 +14,7 @@
<string name="screen_change_account_provider_subtitle">"Χρησιμοποίησε διαφορετικό πάροχο λογαριασμού, όπως τον δικό σου ιδιωτικό διακομιστή ή έναν εργασιακό λογαριασμό."</string>
<string name="screen_change_account_provider_title">"Αλλαγή παρόχου λογαριασμού"</string>
<string name="screen_change_server_error_invalid_homeserver">"Δεν μπορούσαμε να επικοινωνήσουμε με αυτόν τον οικιακό διακομιστή. Βεβαιώσου ότι έχεις εισαγάγει σωστά τη διεύθυνση URL του αρχικού διακομιστή. Εάν η διεύθυνση URL είναι σωστή, επικοινώνησε με τον διαχειριστή του κεντρικού διακομιστή για περαιτέρω βοήθεια."</string>
<string name="screen_change_server_error_invalid_well_known">"Το Sliding sync δεν είναι διαθέσιμο εξαιτίας ενός ζητήματος σε ένα πολύ γνωστό αρχείο:
<string name="screen_change_server_error_invalid_well_known">"Ο διακομιστής δεν είναι διαθέσιμος λόγω προβλήματος στο αρχείο .well-known:
%1$s"</string>
<string name="screen_change_server_form_header">"URL οικιακού διακομιστή"</string>
<string name="screen_change_server_subtitle">"Ποια είναι η διεύθυνση του διακομιστή σου;"</string>

View file

@ -17,6 +17,7 @@
<string name="screen_change_server_error_invalid_well_known">"Server pole saadaval vea tõttu well-known failis:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Valitud teenusepakkuja ei toeta „sliding sync“ režiimi. Rakenduse %1$s kasutamiseks on vaja serverit uuendada."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s ei saa kasutada %2$s koduserverit."</string>
<string name="screen_change_server_form_header">"Koduserveri url"</string>
<string name="screen_change_server_form_notice">"Sisesta domeeni aadress."</string>
<string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string>

View file

@ -14,7 +14,7 @@
<string name="screen_change_account_provider_subtitle">"Utilisez un autre fournisseur de compte, tel que votre propre serveur privé ou un serveur professionnel."</string>
<string name="screen_change_account_provider_title">"Changer de fournisseur de compte"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nous navons pas pu atteindre ce serveur daccueil. Vérifiez que vous avez correctement saisi lURL du serveur daccueil. Si lURL est correcte, contactez ladministrateur de votre serveur daccueil pour obtenir de laide."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync nest pas disponible en raison dun problème dans le well-known file :
<string name="screen_change_server_error_invalid_well_known">"Ce fournisseur de compte nest pas disponible en raison dun problème dans le fichier .well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Le fournisseur de compte sélectionné ne prend pas en charge le sliding sync. Une mise à jour du serveur est nécessaire pour pouvoir utiliser %1$s."</string>
<string name="screen_change_server_form_header">"URL du serveur daccueil"</string>

View file

@ -14,7 +14,7 @@
<string name="screen_change_account_provider_subtitle">"Másik fiókszolgáltató, például a saját privát kiszolgáló vagy egy munkahelyi fiók használata."</string>
<string name="screen_change_account_provider_title">"Fiókszolgáltató módosítása"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nem sikerült elérni ezt a Matrix-kiszolgálót. Ellenőrizze, hogy helyesen adta-e meg a Matrix-kiszolgáló webcímét. Ha a webcím helyes, akkor további segítségért lépjen kapcsolatba a Matrix-kiszolgáló adminisztrátorával."</string>
<string name="screen_change_server_error_invalid_well_known">"A Sliding sync protokoll a well-known fájl problémája miatt nem érhető el:
<string name="screen_change_server_error_invalid_well_known">"A kiszolgáló a well-known fájl problémája miatt nem érhető el:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"A kiválasztott fiókszolgáltató nem támogatja a csúszóablakos szinkronizálást. Az %1$s használatához kiszolgálófrissítés szükséges."</string>
<string name="screen_change_server_form_header">"Matrix-kiszolgáló webcíme"</string>

View file

@ -14,9 +14,10 @@
<string name="screen_change_account_provider_subtitle">"Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego."</string>
<string name="screen_change_account_provider_title">"Zmień dostawcę konta"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync nie jest dostępny z powodu problemu w znanym pliku:
<string name="screen_change_server_error_invalid_well_known">"Serwer nie jest dostępny z powodu problemu pliku .well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Wybrany dostawca konta nie wspiera synchronizacji przesuwnej. Wymagana jest aktualizacja serwera %1$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s nie posiada zezwolenia na dołączenie do %2$s."</string>
<string name="screen_change_server_form_header">"URL serwera domowego"</string>
<string name="screen_change_server_form_notice">"Wprowadź adres domeny."</string>
<string name="screen_change_server_subtitle">"Jaki jest adres Twojego serwera?"</string>

View file

@ -17,6 +17,7 @@
<string name="screen_change_server_error_invalid_well_known">"Sliding sync недоступен из-за проблемы в известном файле:
%1$s"</string>
<string name="screen_change_server_form_header">"URL-адрес домашнего сервера"</string>
<string name="screen_change_server_form_notice">"Введите адрес домена."</string>
<string name="screen_change_server_subtitle">"Какой адрес у вашего сервера?"</string>
<string name="screen_change_server_title">"Выберите свой сервер"</string>
<string name="screen_create_account_title">"Создать учетную запись"</string>

View file

@ -17,6 +17,7 @@
<string name="screen_change_server_error_invalid_well_known">"Server nie je k dispozícii kvôli problému v známom súbore:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Vybraný poskytovateľ účtu nepodporuje kĺzavú synchronizáciu. Na používanie aplikácie %1$s je potrebná aktualizácia servera,"</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s nemá dovolené pripojiť sa k %2$s."</string>
<string name="screen_change_server_form_header">"Adresa URL domovského servera"</string>
<string name="screen_change_server_form_notice">"Zadajte adresu domény."</string>
<string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string>

View file

@ -0,0 +1,189 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
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.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_USER_NAME
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 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 LoginPasswordViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke back callback`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
rule.pressBack()
}
}
@Test
fun `changing login invokes the expected event`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
val userNameHint = rule.activity.getString(CommonStrings.common_username)
rule.onNodeWithText(userNameHint).performTextInput(A_USER_NAME)
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin(A_USER_NAME)
)
}
@Test
fun `changing login removes new lines the expected event`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
val userNameHint = rule.activity.getString(CommonStrings.common_username)
rule.onNodeWithText(userNameHint).performTextInput("a\nb")
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("ab")
)
}
@Test
fun `clearing login invokes the expected event`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(A_USER_NAME),
eventSink = eventsRecorder,
),
)
val a11yClear = rule.activity.getString(CommonStrings.action_clear)
rule.onNodeWithContentDescription(a11yClear).performClick()
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("")
)
}
@Test
fun `changing password invokes the expected event`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
val userNameHint = rule.activity.getString(CommonStrings.common_password)
rule.onNodeWithText(userNameHint).performTextInput(A_PASSWORD)
eventsRecorder.assertSingle(
LoginPasswordEvents.SetPassword(A_PASSWORD)
)
}
@Test
fun `reveal password makes the password visible`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
rule.setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(password = A_PASSWORD),
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist()
// Show password
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
rule.onNodeWithContentDescription(a11yShowPassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertExists()
// Hide password
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
rule.onNodeWithContentDescription(a11yHidePassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist()
}
@Test
fun `when login is empty, continue button is not enabled`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
rule.setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(password = A_PASSWORD),
eventSink = eventsRecorder,
),
)
val continueStr = rule.activity.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsNotEnabled()
}
@Test
fun `when password is empty, continue button is not enabled`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
rule.setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(login = A_USER_NAME),
eventSink = eventsRecorder,
),
)
val continueStr = rule.activity.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsNotEnabled()
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Continue sends expected event`() {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(login = A_USER_NAME, password = A_PASSWORD),
eventSink = eventsRecorder,
),
)
val continueStr = rule.activity.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsEnabled()
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(
LoginPasswordEvents.Submit
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLoginPasswordView(
state: LoginPasswordState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
LoginPasswordView(
state = state,
onBackClick = onBackClick,
)
}
}

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_manually">"Zaloguj się ręcznie"</string>
<string name="screen_onboarding_sign_in_to">"Zaloguj się do %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Zaloguj się za pomocą kodu QR"</string>
<string name="screen_onboarding_sign_up">"Utwórz konto"</string>
<string name="screen_onboarding_welcome_message">"Witamy w %1$s. Szybszy i prostszy niż kiedykolwiek."</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
<string name="screen_onboarding_sign_in_to">"Sign in to %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string>
<string name="screen_onboarding_sign_up">"Create account"</string>
<string name="screen_onboarding_welcome_message">"Welcome to the fastest %1$s ever. Supercharged for speed and simplicity."</string>

View file

@ -25,7 +25,6 @@ import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test

View file

@ -38,7 +38,7 @@ import io.element.android.features.roomdetails.impl.notificationsettings.RoomNot
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.features.roomdetails.impl.securityandprivacy.SecurityAndPrivacyFlowNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
@ -71,7 +71,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
@ -328,13 +328,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode<SecurityAndPrivacyFlowNode>(buildContext)
}
is NavTarget.VerifyUser -> {
val params = VerifySessionEntryPoint.Params(
val params = OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,)
)
verifySessionEntryPoint.nodeBuilder(this, buildContext)
outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.callback(object : VerifySessionEntryPoint.Callback {
.callback(object : OutgoingVerificationEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}

View file

@ -25,7 +25,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
@ -46,7 +46,7 @@ class UserProfileFlowNode @AssistedInject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionIdHolder: CurrentSessionIdHolder,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -110,11 +110,11 @@ class UserProfileFlowNode @AssistedInject constructor(
.build()
}
is NavTarget.VerifyUser -> {
val params = VerifySessionEntryPoint.Params(
val params = OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = false,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
)
verifySessionEntryPoint.nodeBuilder(this, buildContext)
outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(params)
.build()
}

View file

@ -14,7 +14,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.verification.VerificationRequest
interface VerifySessionEntryPoint : FeatureEntryPoint {
interface OutgoingVerificationEntryPoint : FeatureEntryPoint {
data class Params(
val showDeviceVerifiedScreen: Boolean,
val verificationRequest: VerificationRequest.Outgoing,

View file

@ -11,29 +11,29 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): VerifySessionEntryPoint.NodeBuilder {
class DefaultOutgoingVerificationEntryPoint @Inject constructor() : OutgoingVerificationEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): OutgoingVerificationEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : VerifySessionEntryPoint.NodeBuilder {
override fun callback(callback: VerifySessionEntryPoint.Callback): VerifySessionEntryPoint.NodeBuilder {
return object : OutgoingVerificationEntryPoint.NodeBuilder {
override fun callback(callback: OutgoingVerificationEntryPoint.Callback): OutgoingVerificationEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun params(params: VerifySessionEntryPoint.Params): VerifySessionEntryPoint.NodeBuilder {
override fun params(params: OutgoingVerificationEntryPoint.Params): OutgoingVerificationEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun build(): Node {
return parentNode.createNode<VerifySelfSessionNode>(buildContext, plugins)
return parentNode.createNode<OutgoingVerificationNode>(buildContext, plugins)
}
}
}

View file

@ -16,19 +16,19 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class VerifySelfSessionNode @AssistedInject constructor(
class OutgoingVerificationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: VerifySelfSessionPresenter.Factory,
presenterFactory: OutgoingVerificationPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val callback = plugins<VerifySessionEntryPoint.Callback>().first()
private val callback = plugins<OutgoingVerificationEntryPoint.Callback>().first()
private val inputs = inputs<VerifySessionEntryPoint.Params>()
private val inputs = inputs<OutgoingVerificationEntryPoint.Params>()
private val presenter = presenterFactory.create(
showDeviceVerifiedScreen = inputs.showDeviceVerifiedScreen,
@ -38,7 +38,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
VerifySelfSessionView(
OutgoingVerificationView(
state = state,
modifier = modifier,
onLearnMoreClick = callback::onLearnMoreAboutEncryption,

View file

@ -31,30 +31,30 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionStateMachine.State as StateMachineState
import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @AssistedInject constructor(
class OutgoingVerificationPresenter @AssistedInject constructor(
@Assisted private val showDeviceVerifiedScreen: Boolean,
@Assisted private val verificationRequest: VerificationRequest.Outgoing,
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
) : Presenter<VerifySelfSessionState> {
) : Presenter<OutgoingVerificationState> {
@AssistedFactory
interface Factory {
fun create(
verificationRequest: VerificationRequest.Outgoing,
showDeviceVerifiedScreen: Boolean,
): VerifySelfSessionPresenter
): OutgoingVerificationPresenter
}
private val stateMachine = VerifySelfSessionStateMachine(
private val stateMachine = OutgoingVerificationStateMachine(
sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService,
)
@Composable
override fun present(): VerifySelfSessionState {
override fun present(): OutgoingVerificationState {
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
@ -63,17 +63,17 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
when (verificationRequest) {
is VerificationRequest.Outgoing.CurrentSession -> {
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> VerifySelfSessionState.Step.Loading
SessionVerifiedStatus.Unknown -> OutgoingVerificationState.Step.Loading
SessionVerifiedStatus.NotVerified -> {
stateAndDispatch.state.value.toVerificationStep()
}
SessionVerifiedStatus.Verified -> {
if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) {
// The user has verified the session, we need to show the success screen
VerifySelfSessionState.Step.Completed
OutgoingVerificationState.Step.Completed
} else {
// Automatic verification, which can happen on freshly created account, in this case, skip the screen
VerifySelfSessionState.Step.Exit
OutgoingVerificationState.Step.Exit
}
}
}
@ -91,42 +91,44 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
observeVerificationService()
}
fun handleEvents(event: VerifySelfSessionViewEvents) {
fun handleEvents(event: OutgoingVerificationViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}")
when (event) {
// Just relay the event to the state machine
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification(verificationRequest))
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
OutgoingVerificationViewEvents.RequestVerification -> StateMachineEvent.RequestVerification(verificationRequest)
OutgoingVerificationViewEvents.StartSasVerification -> StateMachineEvent.StartSasVerification
OutgoingVerificationViewEvents.ConfirmVerification -> StateMachineEvent.AcceptChallenge
OutgoingVerificationViewEvents.DeclineVerification -> StateMachineEvent.DeclineChallenge
OutgoingVerificationViewEvents.Cancel -> StateMachineEvent.Cancel
OutgoingVerificationViewEvents.Reset -> StateMachineEvent.Reset
}.let { stateMachineEvent ->
stateAndDispatch.dispatchAction(stateMachineEvent)
}
}
return VerifySelfSessionState(
return OutgoingVerificationState(
step = step,
request = verificationRequest,
eventSink = ::handleEvents,
)
}
private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.Step =
private fun StateMachineState?.toVerificationStep(): OutgoingVerificationState.Step =
when (val machineState = this) {
StateMachineState.Initial, null -> {
VerifySelfSessionState.Step.Initial
OutgoingVerificationState.Step.Initial
}
is StateMachineState.RequestingVerification,
is StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted -> {
VerifySelfSessionState.Step.AwaitingOtherDeviceResponse
OutgoingVerificationState.Step.AwaitingOtherDeviceResponse
}
StateMachineState.VerificationRequestAccepted -> {
VerifySelfSessionState.Step.Ready
OutgoingVerificationState.Step.Ready
}
is StateMachineState.Canceled -> {
VerifySelfSessionState.Step.Canceled
OutgoingVerificationState.Step.Canceled
}
is StateMachineState.Verifying -> {
@ -134,15 +136,15 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
is StateMachineState.Verifying.Replying -> AsyncData.Loading()
else -> AsyncData.Uninitialized
}
VerifySelfSessionState.Step.Verifying(machineState.data, async)
OutgoingVerificationState.Step.Verifying(machineState.data, async)
}
StateMachineState.Completed -> {
VerifySelfSessionState.Step.Completed
OutgoingVerificationState.Step.Completed
}
StateMachineState.Exit -> {
VerifySelfSessionState.Step.Exit
OutgoingVerificationState.Step.Exit
}
}

View file

@ -14,10 +14,10 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
import io.element.android.libraries.matrix.api.verification.VerificationRequest
@Immutable
data class VerifySelfSessionState(
data class OutgoingVerificationState(
val step: Step,
val request: VerificationRequest.Outgoing,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
val eventSink: (OutgoingVerificationViewEvents) -> Unit,
) {
@Stable
sealed interface Step {

View file

@ -29,10 +29,10 @@ import kotlin.time.Duration.Companion.seconds
import com.freeletics.flowredux.dsl.State as MachineState
@OptIn(FlowPreview::class)
class VerifySelfSessionStateMachine(
class OutgoingVerificationStateMachine(
private val sessionVerificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>(
) : FlowReduxStateMachine<OutgoingVerificationStateMachine.State, OutgoingVerificationStateMachine.Event>(
initialState = State.Initial,
) {
init {

View file

@ -8,64 +8,64 @@
package io.element.android.features.verifysession.impl.outgoing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
open class OutgoingVerificationStateProvider : PreviewParameterProvider<OutgoingVerificationState> {
override val values: Sequence<OutgoingVerificationState>
get() = sequenceOf(
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Initial,
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Initial,
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.AwaitingOtherDeviceResponse,
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.AwaitingOtherDeviceResponse,
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized),
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized),
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading())
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Canceled
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Ready
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized)
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Completed,
request = anOutgoingSessionVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Completed,
request = anOutgoingUserVerificationRequest(),
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Loading
),
aVerifySelfSessionState(
anOutgoingVerificationState(
step = Step.Exit
),
// Add other state here
@ -75,11 +75,11 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
internal fun anOutgoingUserVerificationRequest() = VerificationRequest.Outgoing.User(userId = UserId("@alice:example.com"))
internal fun anOutgoingSessionVerificationRequest() = VerificationRequest.Outgoing.CurrentSession
internal fun aVerifySelfSessionState(
internal fun anOutgoingVerificationState(
step: Step = Step.Initial,
request: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
eventSink: (OutgoingVerificationViewEvents) -> Unit = {},
) = OutgoingVerificationState(
step = step,
request = request,
eventSink = eventSink,

View file

@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.R
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
import io.element.android.libraries.architecture.AsyncData
@ -49,8 +49,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifySelfSessionView(
state: VerifySelfSessionState,
fun OutgoingVerificationView(
state: OutgoingVerificationState,
onLearnMoreClick: () -> Unit,
onFinish: () -> Unit,
onBack: () -> Unit,
@ -59,12 +59,12 @@ fun VerifySelfSessionView(
val step = state.step
fun cancelOrResetFlow() {
when (step) {
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is Step.Canceled -> state.eventSink(OutgoingVerificationViewEvents.Reset)
Step.Initial, Step.Completed -> onBack()
Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(OutgoingVerificationViewEvents.Cancel)
is Step.Verifying -> {
if (!step.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
state.eventSink(OutgoingVerificationViewEvents.DeclineVerification)
}
}
else -> Unit
@ -98,10 +98,10 @@ fun VerifySelfSessionView(
)
},
header = {
VerifySelfSessionHeader(step = step, request = state.request)
OutgoingVerificationHeader(step = step, request = state.request)
},
footer = {
VerifySelfSessionBottomMenu(
OutgoingVerificationViewBottomMenu(
screenState = state,
onCancelClick = ::cancelOrResetFlow,
onContinueClick = onFinish,
@ -109,7 +109,7 @@ fun VerifySelfSessionView(
},
isScrollable = true,
) {
VerifySelfSessionContent(
OutgoingVerificationContent(
flowState = step,
request = state.request,
onLearnMoreClick = onLearnMoreClick,
@ -119,7 +119,7 @@ fun VerifySelfSessionView(
}
@Composable
private fun VerifySelfSessionHeader(step: Step, request: VerificationRequest.Outgoing) {
private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest.Outgoing) {
val iconStyle = when (step) {
Step.Loading -> error("Should not happen")
Step.Initial -> when (request) {
@ -189,7 +189,7 @@ private fun VerifySelfSessionHeader(step: Step, request: VerificationRequest.Out
}
@Composable
private fun VerifySelfSessionContent(
private fun OutgoingVerificationContent(
flowState: Step,
request: VerificationRequest.Outgoing,
onLearnMoreClick: () -> Unit,
@ -227,8 +227,8 @@ private fun ContentInitial(
}
@Composable
private fun VerifySelfSessionBottomMenu(
screenState: VerifySelfSessionState,
private fun OutgoingVerificationViewBottomMenu(
screenState: OutgoingVerificationState,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
) {
@ -244,7 +244,7 @@ private fun VerifySelfSessionBottomMenu(
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_verification),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
onClick = { eventSink(OutgoingVerificationViewEvents.RequestVerification) },
)
InvisibleButton()
}
@ -264,7 +264,7 @@ private fun VerifySelfSessionBottomMenu(
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start),
onClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
onClick = { eventSink(OutgoingVerificationViewEvents.StartSasVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
@ -287,14 +287,14 @@ private fun VerifySelfSessionBottomMenu(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_match),
onClick = {
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
},
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_they_dont_match),
onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
onClick = { eventSink(OutgoingVerificationViewEvents.DeclineVerification) },
)
}
}
@ -315,8 +315,8 @@ private fun VerifySelfSessionBottomMenu(
@PreviewsDayNight
@Composable
internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreview {
VerifySelfSessionView(
internal fun OutgoingVerificationViewPreview(@PreviewParameter(OutgoingVerificationStateProvider::class) state: OutgoingVerificationState) = ElementPreview {
OutgoingVerificationView(
state = state,
onLearnMoreClick = {},
onFinish = {},

View file

@ -0,0 +1,17 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.outgoing
sealed interface OutgoingVerificationViewEvents {
data object RequestVerification : OutgoingVerificationViewEvents
data object StartSasVerification : OutgoingVerificationViewEvents
data object ConfirmVerification : OutgoingVerificationViewEvents
data object DeclineVerification : OutgoingVerificationViewEvents
data object Cancel : OutgoingVerificationViewEvents
data object Reset : OutgoingVerificationViewEvents
}

View file

@ -1,17 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.verifysession.impl.outgoing
sealed interface VerifySelfSessionViewEvents {
data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents
data object DeclineVerification : VerifySelfSessionViewEvents
data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents
}

View file

@ -9,7 +9,7 @@ package io.element.android.features.verifysession.impl.outgoing
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.outgoing.VerifySelfSessionState.Step
import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -31,13 +31,13 @@ import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class VerifySelfSessionPresenterTest {
class OutgoingVerificationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - Initial state is received`() = runTest {
val presenter = createVerifySelfSessionPresenter(
val presenter = createOutgoingVerificationPresenter(
service = unverifiedSessionService(),
)
presenter.test {
@ -55,7 +55,7 @@ class VerifySelfSessionPresenterTest {
requestSessionVerificationLambda = requestSessionVerificationRecorder,
startVerificationLambda = startVerificationRecorder,
)
val presenter = createVerifySelfSessionPresenter(
val presenter = createOutgoingVerificationPresenter(
service = service,
verificationRequest = anOutgoingSessionVerificationRequest(),
)
@ -75,7 +75,7 @@ class VerifySelfSessionPresenterTest {
requestUserVerificationLambda = requestUserVerificationRecorder,
startVerificationLambda = startVerificationRecorder,
)
val presenter = createVerifySelfSessionPresenter(
val presenter = createOutgoingVerificationPresenter(
service = service,
verificationRequest = anOutgoingUserVerificationRequest(),
)
@ -89,14 +89,14 @@ class VerifySelfSessionPresenterTest {
@Test
fun `present - Cancellation on initial state moves to Exit state`() = runTest {
val presenter = createVerifySelfSessionPresenter(
val presenter = createOutgoingVerificationPresenter(
service = unverifiedSessionService(),
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(Step.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.Cancel)
eventSink(OutgoingVerificationViewEvents.Cancel)
assertThat(awaitItem().step).isEqualTo(Step.Exit)
}
@ -109,10 +109,10 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { },
approveVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
// Cancelling
assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java)
service.emitVerificationFlowState(VerificationFlowState.DidFail)
@ -126,9 +126,9 @@ class VerifySelfSessionPresenterTest {
val service = unverifiedSessionService(
requestSessionVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
awaitItem().eventSink(OutgoingVerificationViewEvents.RequestVerification)
service.emitVerificationFlowState(VerificationFlowState.DidFail)
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
@ -142,10 +142,10 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { },
cancelVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.Cancel)
state.eventSink(OutgoingVerificationViewEvents.Cancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
}
}
@ -156,7 +156,7 @@ class VerifySelfSessionPresenterTest {
requestSessionVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
requestVerificationAndAwaitVerifyingState(service)
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
@ -170,12 +170,12 @@ class VerifySelfSessionPresenterTest {
requestSessionVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.Reset)
state.eventSink(OutgoingVerificationViewEvents.Reset)
// Went back to initial state
assertThat(awaitItem().step).isEqualTo(Step.Initial)
cancelAndIgnoreRemainingEvents()
@ -192,13 +192,13 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { },
approveVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(
service,
SessionVerificationData.Emojis(emojis)
)
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification)
assertThat(awaitItem().step).isEqualTo(
Step.Verifying(
SessionVerificationData.Emojis(emojis),
@ -217,10 +217,10 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { },
declineVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
val presenter = createOutgoingVerificationPresenter(service)
presenter.test {
val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
state.eventSink(OutgoingVerificationViewEvents.DeclineVerification)
assertThat(awaitItem().step).isEqualTo(
Step.Verifying(
SessionVerificationData.Emojis(emptyList()),
@ -241,7 +241,7 @@ class VerifySelfSessionPresenterTest {
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val presenter = createVerifySelfSessionPresenter(
val presenter = createOutgoingVerificationPresenter(
service = service,
showDeviceVerifiedScreen = true,
)
@ -259,7 +259,7 @@ class VerifySelfSessionPresenterTest {
emitVerifiedStatus(SessionVerifiedStatus.Verified)
emitVerificationFlowState(VerificationFlowState.DidFinish)
}
val presenter = createVerifySelfSessionPresenter(
val presenter = createOutgoingVerificationPresenter(
service = service,
showDeviceVerifiedScreen = false,
)
@ -269,13 +269,13 @@ class VerifySelfSessionPresenterTest {
}
}
private suspend fun ReceiveTurbine<VerifySelfSessionState>.requestVerificationAndAwaitVerifyingState(
private suspend fun ReceiveTurbine<OutgoingVerificationState>.requestVerificationAndAwaitVerifyingState(
fakeService: FakeSessionVerificationService,
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
): VerifySelfSessionState {
): OutgoingVerificationState {
var state = awaitItem()
assertThat(state.step).isEqualTo(Step.Initial)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
state.eventSink(OutgoingVerificationViewEvents.RequestVerification)
// Await for other device response:
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)
state = awaitItem()
@ -283,7 +283,7 @@ class VerifySelfSessionPresenterTest {
// Await for the state to be Ready
state = awaitItem()
assertThat(state.step).isEqualTo(Step.Ready)
state.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
state.eventSink(OutgoingVerificationViewEvents.StartSasVerification)
// Await for other device response (again):
fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
state = awaitItem()
@ -321,13 +321,13 @@ class VerifySelfSessionPresenterTest {
}
}
private fun createVerifySelfSessionPresenter(
private fun createOutgoingVerificationPresenter(
service: SessionVerificationService,
verificationRequest: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(),
encryptionService: EncryptionService = FakeEncryptionService(),
showDeviceVerifiedScreen: Boolean = false,
): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(
): OutgoingVerificationPresenter {
return OutgoingVerificationPresenter(
showDeviceVerifiedScreen = showDeviceVerifiedScreen,
verificationRequest = verificationRequest,
sessionVerificationService = service,

View file

@ -26,54 +26,54 @@ import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class VerifySelfSessionViewTest {
class OutgoingVerificationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `back key pressed - when canceled resets the flow`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Canceled,
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Canceled,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Reset)
eventsRecorder.assertSingle(OutgoingVerificationViewEvents.Reset)
}
@Test
fun `back key pressed - when awaiting response cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.AwaitingOtherDeviceResponse,
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.AwaitingOtherDeviceResponse,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
eventsRecorder.assertSingle(OutgoingVerificationViewEvents.Cancel)
}
@Test
fun `back key pressed - when ready to verify cancels the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Ready,
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Ready,
eventSink = eventsRecorder
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.Cancel)
eventsRecorder.assertSingle(OutgoingVerificationViewEvents.Cancel)
}
@Test
fun `back key pressed - when verifying and not loading declines the verification`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Verifying(
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@ -81,15 +81,15 @@ class VerifySelfSessionViewTest {
),
)
rule.pressBackKey()
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
eventsRecorder.assertSingle(OutgoingVerificationViewEvents.DeclineVerification)
}
@Test
fun `back key pressed - when verifying and loading does nothing`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Verifying(
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Loading(),
),
@ -103,10 +103,10 @@ class VerifySelfSessionViewTest {
@Test
fun `back key pressed - on Completed exits the flow`() {
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
rule.setOutgoingVerificationView(
onBack = callback,
state = aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Completed,
state = anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Completed,
),
)
rule.pressBackKey()
@ -115,11 +115,11 @@ class VerifySelfSessionViewTest {
@Test
fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>(expectEvents = false)
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Completed,
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Completed,
eventSink = eventsRecorder
),
onFinished = callback,
@ -130,10 +130,10 @@ class VerifySelfSessionViewTest {
@Test
fun `clicking on they match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Verifying(
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@ -141,15 +141,15 @@ class VerifySelfSessionViewTest {
),
)
rule.clickOn(R.string.screen_session_verification_they_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.ConfirmVerification)
eventsRecorder.assertSingle(OutgoingVerificationViewEvents.ConfirmVerification)
}
@Test
fun `clicking on they do not match emits the expected event`() {
val eventsRecorder = EventsRecorder<VerifySelfSessionViewEvents>()
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
step = VerifySelfSessionState.Step.Verifying(
val eventsRecorder = EventsRecorder<OutgoingVerificationViewEvents>()
rule.setOutgoingVerificationView(
anOutgoingVerificationState(
step = OutgoingVerificationState.Step.Verifying(
data = aEmojisSessionVerificationData(),
state = AsyncData.Uninitialized,
),
@ -157,17 +157,17 @@ class VerifySelfSessionViewTest {
),
)
rule.clickOn(R.string.screen_session_verification_they_dont_match)
eventsRecorder.assertSingle(VerifySelfSessionViewEvents.DeclineVerification)
eventsRecorder.assertSingle(OutgoingVerificationViewEvents.DeclineVerification)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
state: VerifySelfSessionState,
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOutgoingVerificationView(
state: OutgoingVerificationState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
onBack: () -> Unit = EnsureNeverCalled(),
) {
setContent {
VerifySelfSessionView(
OutgoingVerificationView(
state = state,
onLearnMoreClick = onLearnMoreClick,
onFinish = onFinished,

View file

@ -11,10 +11,10 @@ firebaseAppDistribution = "5.1.1"
# AndroidX
core = "1.16.0"
datastore = "1.1.4"
datastore = "1.1.6"
constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1"
lifecycle = "2.8.7"
lifecycle = "2.9.0"
activity = "1.10.1"
media3 = "1.6.1"
camera = "1.4.2"
@ -176,7 +176,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.5.0"
sqlite = "androidx.sqlite:sqlite-ktx:2.5.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@ -207,7 +207,7 @@ anvil_compiler_api = { module = "dev.zacsweers.anvil:compiler-api", version.ref
anvil_compiler_utils = { module = "dev.zacsweers.anvil:compiler-utils", version.ref = "anvil" }
# Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.10.0"
element_call_embedded = "io.element.android:element-call-embedded:0.9.0"
# Auto services
google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }

Binary file not shown.

View file

@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

4
gradlew vendored
View file

@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

4
gradlew.bat vendored
View file

@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View file

@ -16,8 +16,6 @@ import androidx.core.text.util.LinkifyCompat
import timber.log.Timber
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.isNotEmpty
import kotlin.collections.iterator
/**
* Helper class to linkify text while preserving existing URL spans.

View file

@ -1,34 +0,0 @@
/*
* Copyright 2023, 2024 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.designsystem.components.keyboard
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Inspired from https://stackoverflow.com/questions/68847559/how-can-i-detect-keyboard-opening-and-closing-in-jetpack-compose
*/
enum class Keyboard {
Opened,
Closed
}
// Note: it does not work as expected...
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun keyboardAsState(): State<Keyboard> {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val isResumed = lifecycle.currentState == Lifecycle.State.RESUMED
return rememberUpdatedState(if (WindowInsets.isImeVisible && isResumed) Keyboard.Opened else Keyboard.Closed)
}

View file

@ -9,16 +9,22 @@ package io.element.android.libraries.push.impl.notifications
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Helper to resolve a valid [NotifiableEvent] from a [NotificationData].
@ -31,7 +37,7 @@ interface CallNotificationEventResolver {
* @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`.
* @return a [NotifiableEvent] if the notification data is a call notification, null otherwise
*/
fun resolveEvent(
suspend fun resolveEvent(
sessionId: SessionId,
notificationData: NotificationData,
forceNotify: Boolean = false,
@ -41,8 +47,10 @@ interface CallNotificationEventResolver {
@ContributesBinding(AppScope::class)
class DefaultCallNotificationEventResolver @Inject constructor(
private val stringProvider: StringProvider,
private val appForegroundStateService: AppForegroundStateService,
private val clientProvider: MatrixClientProvider,
) : CallNotificationEventResolver {
override fun resolveEvent(
override suspend fun resolveEvent(
sessionId: SessionId,
notificationData: NotificationData,
forceNotify: Boolean
@ -50,8 +58,32 @@ class DefaultCallNotificationEventResolver @Inject constructor(
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
?: throw ResolvingException("content is not a call notify")
val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
// We need the sync service working to get the updated room info
val isRoomCallActive = runCatching {
if (content.type == CallNotifyType.RING) {
appForegroundStateService.updateHasRingingCall(true)
val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found")
val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found")
// Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant
val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false
// We no longer need the sync service to be active because of a call notification.
appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
isActive
} else {
// If the call notification is not of ringing type, we don't need to check if the call is active
false
}
}.onFailure {
// Make sure to reset the hasRingingCall state in case of failure
appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
}.getOrDefault(false)
notificationData.run {
if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) {
if (content.type == CallNotifyType.RING && isRoomCallActive && !forceNotify) {
NotifiableRingingCallEvent(
sessionId = sessionId,
roomId = roomId,
@ -70,9 +102,7 @@ class DefaultCallNotificationEventResolver @Inject constructor(
senderAvatarUrl = senderAvatarUrl,
)
} else {
val now = System.currentTimeMillis()
val elapsed = now - timestamp
Timber.d("Event $eventId is call notify but should not ring: $timestamp vs $now ($elapsed ms elapsed), notify: ${content.type}")
Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}")
// Create a simple message notification event
buildNotifiableMessageEvent(
sessionId = sessionId,

View file

@ -12,8 +12,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
data class NotifiableRingingCallEvent(
override val sessionId: SessionId,
@ -31,13 +29,4 @@ data class NotifiableRingingCallEvent(
val roomAvatarUrl: String? = null,
val callNotifyType: CallNotifyType,
val timestamp: Long,
) : NotifiableEvent {
companion object {
fun shouldRing(callNotifyType: CallNotifyType, timestamp: Long): Boolean {
val timeout = 10.seconds.inWholeMilliseconds
val elapsed = Instant.now().toEpochMilli() - timestamp
// Only ring if the type is RING and the elapsed time is less than the timeout
return callNotifyType == CallNotifyType.RING && elapsed < timeout
}
}
}
) : NotifiableEvent

View file

@ -0,0 +1,171 @@
/*
* 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.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultCallNotificationEventResolverTest {
@Test
fun `resolve CallNotify - RING when call is still ongoing`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
// The call is still ongoing
initialRoomInfo = aRoomInfo(hasRoomCall = true),
)
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val resolver = createDefaultNotifiableEventResolver(
clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
)
val expectedResult = NotifiableRingingCallEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = A_ROOM_NAME,
editedEventId = null,
description = "📹 Incoming call",
timestamp = 567L,
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = A_USER_NAME_2,
senderAvatarUrl = null,
callNotifyType = CallNotifyType.RING,
)
val notificationData = aNotificationData(
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
)
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - NOTIFY`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
// The call already ended
initialRoomInfo = aRoomInfo(hasRoomCall = true),
)
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val resolver = createDefaultNotifiableEventResolver(
clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
)
val expectedResult = NotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = A_ROOM_NAME,
editedEventId = null,
body = "📹 Incoming call",
timestamp = 567L,
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = A_USER_NAME_2,
noisy = true,
imageUriString = null,
imageMimeType = null,
threadId = null,
type = "m.call.notify",
)
val notificationData = aNotificationData(
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.NOTIFY)
)
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - RING but timed out displays the same as NOTIFY`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
// The call already ended
initialRoomInfo = aRoomInfo(hasRoomCall = false),
)
)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val resolver = createDefaultNotifiableEventResolver(
clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
)
val expectedResult = NotifiableMessageEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = A_ROOM_NAME,
editedEventId = null,
body = "📹 Incoming call",
timestamp = 567L,
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = A_USER_NAME_2,
noisy = true,
imageUriString = null,
imageMimeType = null,
threadId = null,
type = "m.call.notify",
)
val notificationData = aNotificationData(
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
)
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
private fun createDefaultNotifiableEventResolver(
stringProvider: FakeStringProvider = FakeStringProvider(defaultResult = "\uD83D\uDCF9 Incoming call"),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
) = DefaultCallNotificationEventResolver(
stringProvider = stringProvider,
appForegroundStateService = appForegroundStateService,
clientProvider = clientProvider,
)
}

View file

@ -49,10 +49,9 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest
@ -606,80 +605,8 @@ class DefaultNotifiableEventResolverTest {
}
@Test
fun `resolve CallNotify - ringing`() = runTest {
val timestamp = DefaultSystemClock().epochMillis()
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.RING
),
timestamp = timestamp,
)
)
)
val expectedResult = ResolvedPushEvent.Event(
NotifiableRingingCallEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = A_ROOM_NAME,
editedEventId = null,
description = "📹 Incoming call",
timestamp = timestamp,
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = A_USER_NAME_2,
senderAvatarUrl = null,
callNotifyType = CallNotifyType.RING,
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - ring but timed out displays the same as notify`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.RING
),
timestamp = 0L,
)
)
)
val expectedResult = ResolvedPushEvent.Event(
NotifiableMessageEvent(
sessionId = A_SESSION_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
noisy = true,
timestamp = 0L,
senderDisambiguatedDisplayName = A_USER_NAME_2,
senderId = A_USER_ID_2,
body = "📹 Incoming call",
roomId = A_ROOM_ID,
threadId = null,
roomName = A_ROOM_NAME,
canBeReplaced = false,
isRedacted = false,
imageUriString = null,
imageMimeType = null,
type = EventType.CALL_NOTIFY,
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - notify`() = runTest {
fun `resolve CallNotify - goes through CallNotificationEventResolver`() = runTest {
val callNotificationEventResolver = FakeCallNotificationEventResolver()
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
@ -688,7 +615,8 @@ class DefaultNotifiableEventResolverTest {
CallNotifyType.NOTIFY
),
)
)
),
callNotificationEventResolver = callNotificationEventResolver,
)
val expectedResult = ResolvedPushEvent.Event(
NotifiableMessageEvent(
@ -710,6 +638,7 @@ class DefaultNotifiableEventResolverTest {
type = EventType.CALL_NOTIFY,
)
)
callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) }
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
}
@ -804,6 +733,7 @@ class DefaultNotifiableEventResolverTest {
notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<NotificationData?> = Result.success(null),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context
notificationService?.givenGetNotificationResult(notificationResult)
@ -824,9 +754,7 @@ class DefaultNotifiableEventResolverTest {
notificationMediaRepoFactory = notificationMediaRepoFactory,
context = context,
permalinkParser = FakePermalinkParser(),
callNotificationEventResolver = DefaultCallNotificationEventResolver(
stringProvider = AndroidStringProvider(context.resources)
),
callNotificationEventResolver = callNotificationEventResolver,
appPreferencesStore = appPreferencesStore,
)
}

View file

@ -18,7 +18,7 @@ class FakeCallNotificationEventResolver(
lambdaError()
},
) : CallNotificationEventResolver {
override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result<NotifiableEvent> {
override suspend fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result<NotifiableEvent> {
return resolveEventLambda(sessionId, notificationData, forceNotify)
}
}

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_avatar">"Аватар"</string>
<string name="a11y_delete">"Выдаліць"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"Уведзеная лічба %1$d"</item>
@ -7,6 +8,7 @@
<item quantity="many">"Уведзена %1$d лічб"</item>
</plurals>
<string name="a11y_hide_password">"Схаваць пароль"</string>
<string name="a11y_join_call">"Далучыцца да выкліку"</string>
<string name="a11y_jump_to_bottom">"Перайсці ўніз"</string>
<string name="a11y_notifications_mentions_only">"Толькі згадкі"</string>
<string name="a11y_notifications_muted">"Гук адключаны"</string>
@ -31,6 +33,7 @@
<string name="a11y_show_password">"Паказаць пароль"</string>
<string name="a11y_start_call">"Пазваніць"</string>
<string name="a11y_user_menu">"Меню карыстальніка"</string>
<string name="a11y_view_details">"Паглядзець падрабязнасці"</string>
<string name="a11y_voice_message_record">"Запісаць галасавое паведамленне."</string>
<string name="a11y_voice_message_stop_recording">"Спыніць запіс"</string>
<string name="action_accept">"Прыняць"</string>
@ -57,6 +60,7 @@
<string name="action_delete_poll">"Выдаліць апытанне"</string>
<string name="action_disable">"Адключыць"</string>
<string name="action_discard">"Адмяніць"</string>
<string name="action_dismiss">"Aдхіліць"</string>
<string name="action_done">"Гатова"</string>
<string name="action_edit">"Рэдагаваць"</string>
<string name="action_edit_poll">"Рэдагаваць апытанне"</string>

View file

@ -111,6 +111,7 @@
<string name="action_report">"Отчет"</string>
<string name="action_report_bug">"Сообщить об ошибке"</string>
<string name="action_report_content">"Пожаловаться на содержание"</string>
<string name="action_report_dm">"Пожаловаться на беседу"</string>
<string name="action_report_room">"Комната отчетов"</string>
<string name="action_reset">"Сбросить"</string>
<string name="action_reset_identity">"Сбросить идентификацию"</string>
@ -264,6 +265,7 @@
<string name="common_shared_location">"Поделился местоположением"</string>
<string name="common_signing_out">"Выход…"</string>
<string name="common_something_went_wrong">"Что-то пошло не так"</string>
<string name="common_something_went_wrong_message">"Мы столкнулись с проблемой. Пожалуйста, попробуйте еще раз."</string>
<string name="common_starting_chat">"Чат запускается…"</string>
<string name="common_sticker">"Стикер"</string>
<string name="common_success">"Успешно"</string>

View file

@ -351,6 +351,11 @@ Are you sure you want to continue?"</string>
<string name="screen_bottom_sheet_manage_room_member_remove">"Remove from room"</string>
<string name="screen_bottom_sheet_manage_room_member_remove_confirmation_title">"Remove member and ban from joining in the future?"</string>
<string name="screen_bottom_sheet_manage_room_member_removing_user">"Removing %1$s…"</string>
<string name="screen_bottom_sheet_manage_room_member_unban">"Unban from room"</string>
<string name="screen_bottom_sheet_manage_room_member_unban_member_confirmation_action">"Unban"</string>
<string name="screen_bottom_sheet_manage_room_member_unban_member_confirmation_description">"They would be able to join the room again if invited"</string>
<string name="screen_bottom_sheet_manage_room_member_unban_member_confirmation_title">"Are you sure you want to unban this member?"</string>
<string name="screen_bottom_sheet_manage_room_member_unbanning_user">"Unbanning $1%s"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>

View file

@ -32,7 +32,7 @@ private const val versionYear = 25
private const val versionMonth = 5
// Note: must be in [0,99]
private const val versionReleaseNumber = 1
private const val versionReleaseNumber = 2
object Versions {
const val VERSION_CODE = (2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac3a523da98d3cf64cd908b83824d75e060b14cb581b4015a75e943524547048
size 16399

File diff suppressed because it is too large Load diff

View file

@ -27,8 +27,6 @@ import timber.log.Timber
import javax.inject.Inject
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.iterator
import kotlin.collections.orEmpty
@ContributesMultibinding(AppScope::class)
class SentryAnalyticsProvider @Inject constructor(