Merge branch 'develop' into feature/fga/knock_requests_sdk

This commit is contained in:
ganfra 2024-12-18 17:21:16 +01:00
commit 01f7fc20f4
391 changed files with 6199 additions and 1991 deletions

View file

@ -4,15 +4,26 @@
<string name="screen_knock_requests_list_accept_all_alert_description">"Opravdu chcete přijmout všechny žádosti o vstup?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Přijmout všechny požadavky"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Přijmout vše"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Nemohli jsme přijmout všechny žádosti. Chcete to zkusit znovu?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Nepodařilo se přijmout všechny žádosti"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Přijímání všech žádostí o vstup"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"Tuto žádost jsme nemohli přijmout. Chcete to zkusit znovu?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Žádost se nepodařilo přijmout"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Přijímání žádosti o vstup"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ano, odmítnout a vykázat"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Opravdu chcete odmítnout a vykázat %1$s? Tento uživatel nebude moci znovu požádat o vstup do této místnosti."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Odmítnout a zakázat vstup"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Odmítání vstupu a vykázání"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ano, odmítnout"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Opravdu chcete odmítnout %1$s žádost o vstup do této místnosti?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Odmítnout vstup"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Odmítnout a vykázat"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Tuto žádost jsme nemohli odmítnout. Chcete to zkusit znovu?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Žádost se nepodařilo odmítnout"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Odmítání žádosti o vstup"</string>
<string name="screen_knock_requests_list_empty_state_description">"Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde."</string>
<string name="screen_knock_requests_list_empty_state_title">"Žádná čekající žádost o vstup"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Načítání žádostí o vstup…"</string>
<string name="screen_knock_requests_list_title">"Žádosti o vstup"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d další chce vstoupit do této místnosti"</item>

View file

@ -4,13 +4,17 @@
<string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Απόρριψη και αποκλεισμός πρόσβασης"</string>
<string name="screen_knock_requests_list_ban_loading_title">"Γίνεται απόρριψη και αποκλεισμός πρόσβασης"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Ναι, απόρριψη"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Σίγουρα θες να απορρίψεις το αίτημα του χρήστη %1$s να συμμετάσχει στο δωμάτιο;"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Απόρριψη πρόσβασης"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Απόρριψη και αποκλεισμός"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string>
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"Kyllä, hyväksy kaikki"</string>
<string name="screen_knock_requests_list_accept_all_alert_description">"Haluatko varmasti hyväksyä kaikki liittymispyynnöt?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Hyväksy kaikki pyynnöt"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Hyväksy kaikki"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Kyllä, hylkää ja anna porttikielto"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä huoneeseen ja antaa hänelle porttikiellon? Hän ei voi enää pyytää lupaa liittyä tähän huoneeseen."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Hylkää ja anna porttikielto"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Kyllä, hylkää"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä tähän huoneeseen?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Hylkää pyyntö"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Hylkää ja anna porttikielto"</string>
<string name="screen_knock_requests_list_empty_state_description">"Kun joku pyytää liittyä huoneeseen, näet hänen pyyntönsä täällä."</string>
<string name="screen_knock_requests_list_empty_state_title">"Ei odottavia liittymispyyntöjä"</string>
<string name="screen_knock_requests_list_title">"Liittymispyynnöt"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s +%2$d muu haluavat liittyä tähän huoneeseen"</item>
<item quantity="other">"%1$s +%2$d muuta haluavat liittyä tähän huoneeseen"</item>
</plurals>
<string name="screen_room_multiple_knock_requests_view_all_button_title">"Näytä kaikki"</string>
<string name="screen_room_single_knock_request_accept_button_title">"Hyväksy"</string>
<string name="screen_room_single_knock_request_title">"%1$s haluaa liittyä tähän huoneeseen"</string>
<string name="screen_room_single_knock_request_view_button_title">"Näytä"</string>
</resources>

View file

@ -4,15 +4,26 @@
<string name="screen_knock_requests_list_accept_all_alert_description">"Êtes-vous sûr de vouloir accepter toutes les demandes pour rejoindre le salon ?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Tout accepter"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Tout accepter"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Toutes les demandes nont pas pu être acceptées. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Toutes les demandes nont pas été acceptées"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Accepter toutes les demandes à rejoindre"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"La demande na pas pu être acceptée. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Impossible daccepter la demande"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Accepter la demande à rejoindre"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Oui, rejeter et bannir"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Êtes-vous sûr de vouloir rejeter la demande et bannir %1$s ? Cet utilisateur ne pourra pas demander à nouveau à rejoindre ce salon."</string>
<string name="screen_knock_requests_list_ban_alert_title">"Refuser et interdire laccès"</string>
<string name="screen_knock_requests_list_ban_loading_title">"En cours de traitement…"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Oui, refuser"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Êtes-vous sûr de vouloir refuser la demande de %1$s à rejoindre le salon ?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Refuser laccès"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Refuser et bannir"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Nous navons pas pu refuser cette demande. Voulez-vous réessayer ?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Echec"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Traitement en cours…"</string>
<string name="screen_knock_requests_list_empty_state_description">"Lorsque quelquun demandera à rejoindre le salon, vous pourrez voir sa demande ici."</string>
<string name="screen_knock_requests_list_empty_state_title">"Personne ne demande à rejoindre le salon"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Chargement…"</string>
<string name="screen_knock_requests_list_title">"Demandes en attente"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s et %2$d autre personne souhaitent rejoindre ce salon"</item>

View file

@ -4,15 +4,26 @@
<string name="screen_knock_requests_list_accept_all_alert_description">"Biztos, hogy elfogadja az összes csatlakozási kérelmet?"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Minden kérés elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Összes elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Nem sikerült az összes kérés fogadása. Újra megpróbálja?"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Nem sikerült az összes kérés elfogadása"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Összes csatlakozási kérés elfogadása"</string>
<string name="screen_knock_requests_list_accept_failed_alert_description">"Nem sikerült elfogadni a kérést. Megpróbálja újra?"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Nem sikerült elfogadni a kérést"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Csatlakozási kérés elfogadása"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Igen, elutasítás és kitiltás"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Biztos, hogy elutasítja %1$s kérését és ki is tiltja? Többé nem fogja tudni azt kérni, hogy csatlakozhasson ehhez a szobához."</string>
<string name="screen_knock_requests_list_ban_alert_title">"A hozzáférés elutasítása és kitiltás"</string>
<string name="screen_knock_requests_list_ban_loading_title">"A hozzáférés megtagadása és kitiltás"</string>
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"Igen, elutasítás"</string>
<string name="screen_knock_requests_list_decline_alert_description">"Biztos, hogy elutasítja %1$s kérését, hogy csatlakozzon a szobához?"</string>
<string name="screen_knock_requests_list_decline_alert_title">"Hozzáférés elutasítása"</string>
<string name="screen_knock_requests_list_decline_and_ban_action_title">"Elutasítás és kitiltás"</string>
<string name="screen_knock_requests_list_decline_failed_alert_description">"Nem sikerült elutasítani a kérést. Megpróbálja újra?"</string>
<string name="screen_knock_requests_list_decline_failed_alert_title">"Nem sikerült elutasítani a kérést"</string>
<string name="screen_knock_requests_list_decline_loading_title">"Csatlakozási kérés elutasítása"</string>
<string name="screen_knock_requests_list_empty_state_description">"Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését."</string>
<string name="screen_knock_requests_list_empty_state_title">"Nincs függőben lévő csatlakozási kérelem"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Csatlakozási kérések betöltése…"</string>
<string name="screen_knock_requests_list_title">"Csatlakozási kérelmek"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához"</item>

View file

@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.licenses.impl.list
sealed interface DependencyLicensesListEvent {
data class SetFilter(val filter: String) : DependencyLicensesListEvent
}

View file

@ -29,6 +29,10 @@ class DependencyLicensesListPresenter @Inject constructor(
var licenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
var filteredLicenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
var filter by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
runCatching {
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
@ -36,6 +40,32 @@ class DependencyLicensesListPresenter @Inject constructor(
licenses = AsyncData.Failure(it)
}
}
return DependencyLicensesListState(licenses = licenses)
LaunchedEffect(filter, licenses.dataOrNull()) {
val data = licenses.dataOrNull()
val safeFilter = filter.trim()
if (data != null && safeFilter.isNotEmpty()) {
filteredLicenses = AsyncData.Success(data.filter {
it.safeName.contains(safeFilter, ignoreCase = true) ||
it.groupId.contains(safeFilter, ignoreCase = true) ||
it.artifactId.contains(safeFilter, ignoreCase = true)
}.toPersistentList())
} else {
filteredLicenses = licenses
}
}
fun handleEvent(dependencyLicensesListEvent: DependencyLicensesListEvent) {
when (dependencyLicensesListEvent) {
is DependencyLicensesListEvent.SetFilter -> {
filter = dependencyLicensesListEvent.filter
}
}
}
return DependencyLicensesListState(
licenses = filteredLicenses,
filter = filter,
eventSink = ::handleEvent,
)
}
}

View file

@ -13,4 +13,6 @@ import kotlinx.collections.immutable.ImmutableList
data class DependencyLicensesListState(
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
val filter: String,
val eventSink: (DependencyLicensesListEvent) -> Unit,
)

View file

@ -11,28 +11,49 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.features.licenses.impl.model.License
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class DependencyLicensesListStateProvider : PreviewParameterProvider<DependencyLicensesListState> {
override val values: Sequence<DependencyLicensesListState>
get() = sequenceOf(
DependencyLicensesListState(
aDependencyLicensesListState(
licenses = AsyncData.Loading()
),
DependencyLicensesListState(
aDependencyLicensesListState(
licenses = AsyncData.Failure(Exception("Failed to load licenses"))
),
DependencyLicensesListState(
aDependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
)
)
),
aDependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
),
filter = "a filter",
),
)
}
private fun aDependencyLicensesListState(
licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
filter: String = "",
): DependencyLicensesListState {
return DependencyLicensesListState(
licenses = licenses,
filter = filter,
eventSink = {},
)
}
internal fun aDependencyLicenseItem(
name: String? = "A dependency",
) = DependencyLicenseItem(

View file

@ -7,31 +7,36 @@
package io.element.android.features.licenses.impl.list
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun DependencyLicensesListView(
state: DependencyLicensesListState,
@ -48,48 +53,64 @@ fun DependencyLicensesListView(
)
},
) { contentPadding ->
LazyColumn(
Column(
modifier = Modifier
.padding(contentPadding)
.padding(horizontal = 16.dp)
) {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
text = stringResource(CommonStrings.common_error),
modifier = Modifier.padding(16.dp)
)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
if (state.licenses.isSuccess()) {
// Search field
OutlinedTextField(
value = state.filter,
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = null,
)
},
modifier = Modifier.fillMaxWidth(),
)
}
LazyColumn {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
text = stringResource(CommonStrings.common_error),
modifier = Modifier.padding(16.dp)
)
}
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
append(license.groupId)
append(":")
append(license.artifactId)
append(":")
append(license.version)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
},
onClick = {
onOpenLicense(license)
}
)
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
append(license.groupId)
append(":")
append(license.artifactId)
append(":")
append(license.version)
}
)
},
onClick = {
onOpenLicense(license)
}
)
}
}
}
}

View file

@ -33,6 +33,7 @@ class DependencyLicensesListPresenterTest {
val finalState = awaitItem()
assertThat(finalState.licenses.isSuccess()).isTrue()
assertThat(finalState.licenses.dataOrNull()).isEmpty()
assertThat(finalState.filter).isEqualTo("")
}
}
@ -54,6 +55,40 @@ class DependencyLicensesListPresenterTest {
}
}
@Test
fun `present - initial state, one license, set filter`() = runTest {
val anItem = aDependencyLicenseItem()
val presenter = createPresenter {
listOf(anItem)
}
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val loadedState = awaitItem()
assertThat(loadedState.licenses.isSuccess()).isTrue()
assertThat(loadedState.licenses.dataOrNull()!!.size).isEqualTo(1)
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("dep"))
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(state.filter).isEqualTo("dep")
}
loadedState.eventSink(DependencyLicensesListEvent.SetFilter("bleh"))
skipItems(1)
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(0)
assertThat(state.filter).isEqualTo("bleh")
}
loadedState.eventSink(DependencyLicensesListEvent.SetFilter(""))
skipItems(1)
awaitItem().let { state ->
assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(state.filter).isEqualTo("")
}
}
}
private fun createPresenter(
provideResult: () -> List<DependencyLicenseItem>
) = DependencyLicensesListPresenter(

View file

@ -3,6 +3,7 @@
<string name="screen_app_lock_biometric_authentication">"biometrinen tunnistus"</string>
<string name="screen_app_lock_biometric_unlock">"biometrinen tunnistus"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Avaa biometrisellä"</string>
<string name="screen_app_lock_confirm_biometric_authentication_android">"Vahvista biometrinen tunniste"</string>
<string name="screen_app_lock_forgot_pin">"Unohtuiko PIN-koodi?"</string>
<string name="screen_app_lock_settings_change_pin">"Vaihda PIN-koodi"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Salli biometrinen tunnistus"</string>

View file

@ -47,6 +47,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.uiUtils)

View file

@ -47,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
@ -55,6 +56,8 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
@ -97,6 +100,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider,
private val timelineController: TimelineController,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@ -436,7 +440,15 @@ class MessagesFlowNode @AssistedInject constructor(
senderId = event.senderId,
senderName = event.safeSenderName,
senderAvatar = event.senderAvatar.url,
dateSent = event.sentTime,
dateSent = dateFormatter.format(
event.sentTimeMillis,
mode = DateFormatterMode.Day,
),
dateSentFull = dateFormatter.format(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
waveform = (content as? TimelineItemVoiceContent)?.waveform,
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,

View file

@ -274,7 +274,8 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)

View file

@ -37,6 +37,8 @@ import io.element.android.features.messages.impl.timeline.model.event.canBeCopie
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@ -64,6 +66,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
private val featureFlagService: FeatureFlagService,
private val dateFormatter: DateFormatter,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@ -131,6 +134,11 @@ class DefaultActionListPresenter @AssistedInject constructor(
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
target.value = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = dateFormatter.format(
timelineItem.sentTimeMillis,
DateFormatterMode.Full,
useRelative = true,
),
displayEmojiReactions = displayEmojiReactions,
verifiedUserSendFailure = verifiedUserSendFailure,
actions = actions.toImmutableList()
@ -170,6 +178,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
add(TimelineItemAction.EditCaption)
add(TimelineItemAction.RemoveCaption)
}
} else if (timelineItem.content is TimelineItemPollContent) {
add(TimelineItemAction.EditPoll)
} else {
add(TimelineItemAction.Edit)
}

View file

@ -24,6 +24,7 @@ data class ActionListState(
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val sentTimeFull: String,
val displayEmojiReactions: Boolean,
val verifiedUserSendFailure: VerifiedUserSendFailure,
val actions: ImmutableList<TimelineItemAction>,

View file

@ -37,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
event = aTimelineItemEvent(
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@ -49,6 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayNameAmbiguous = true,
timelineItemReactions = reactionsState,
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@ -62,6 +64,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemVideoContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@ -75,6 +78,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemFileContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@ -88,6 +92,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemAudioContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@ -101,6 +106,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemVoiceContent(caption = null),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(
@ -114,6 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemLocationContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@ -125,6 +132,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemLocationContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@ -136,6 +144,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
content = aTimelineItemPollContent(),
timelineItemReactions = reactionsState
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(),
@ -147,6 +156,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
timelineItemReactions = reactionsState,
messageShield = MessageShield.UnknownDevice(isCritical = true)
),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
@ -155,6 +165,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
anActionListState(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
sentTimeFull = "January 1, 1970 at 12:00AM",
displayEmojiReactions = true,
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
actions = aTimelineItemActionList(),
@ -192,6 +203,7 @@ fun aTimelineItemActionList(
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
return setOf(
TimelineItemAction.EndPoll,
TimelineItemAction.EditPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,

View file

@ -185,6 +185,7 @@ private fun ActionListViewContent(
Column {
MessageSummary(
event = target.event,
sentTimeFull = target.sentTimeFull,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
@ -245,7 +246,11 @@ private fun ActionListViewContent(
@Suppress("MultipleEmitters") // False positive
@Composable
private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) {
private fun MessageSummary(
event: TimelineItem.Event,
sentTimeFull: String,
modifier: Modifier = Modifier,
) {
val content: @Composable () -> Unit
val icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender)) }
val contentStyle = ElementTheme.typography.fontBodyMdRegular.copy(color = MaterialTheme.colorScheme.secondary)
@ -300,20 +305,23 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
icon()
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
SenderName(
senderId = event.senderId,
senderProfile = event.senderProfile,
senderNameMode = SenderNameMode.ActionList,
)
Row {
SenderName(
modifier = Modifier.weight(1f),
senderId = event.senderId,
senderProfile = event.senderProfile,
senderNameMode = SenderNameMode.ActionList,
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = sentTimeFull,
style = ElementTheme.typography.fontBodyXsRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
)
}
content()
}
Spacer(modifier = Modifier.width(16.dp))
Text(
event.sentTime,
style = ElementTheme.typography.fontBodyXsRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.End,
)
}
}

View file

@ -11,30 +11,30 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
sealed class TimelineItemAction(
enum class TimelineItemAction(
@StringRes val titleRes: Int,
@DrawableRes val icon: Int,
val destructive: Boolean = false
) {
data object ViewInTimeline : TimelineItemAction(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on)
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object CopyText : TimelineItemAction(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy)
data object CopyCaption : TimelineItemAction(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy)
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit)
data object EditCaption : TimelineItemAction(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit)
data object AddCaption : TimelineItemAction(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit)
data object RemoveCaption : TimelineItemAction(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_delete, destructive = true)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)
data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin)
data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin)
ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on),
Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward),
CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy),
CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy),
CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link),
Redact(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true),
Reply(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply),
ReplyInThread(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply),
Edit(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit),
EditPoll(CommonStrings.action_edit_poll, CompoundDrawables.ic_compound_edit),
EditCaption(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit),
AddCaption(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit),
RemoveCaption(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_close, destructive = true),
ViewSource(CommonStrings.action_view_source, CompoundDrawables.ic_compound_code),
ReportContent(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true),
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
}

View file

@ -7,21 +7,25 @@
package io.element.android.features.messages.impl.actionlist.model
import androidx.annotation.VisibleForTesting
class TimelineItemActionComparator : Comparator<TimelineItemAction> {
// See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392
private val orderedList = listOf(
@VisibleForTesting
val orderedList = listOf(
TimelineItemAction.EndPoll,
TimelineItemAction.ViewInTimeline,
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyText,
TimelineItemAction.EditPoll,
TimelineItemAction.AddCaption,
TimelineItemAction.EditCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
TimelineItemAction.CopyText,
TimelineItemAction.CopyCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,

View file

@ -14,9 +14,9 @@ class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProc
override fun process(actions: List<TimelineItemAction>): List<TimelineItemAction> {
return buildList {
add(TimelineItemAction.ViewInTimeline)
actions.firstOrNull { it is TimelineItemAction.Unpin }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.Forward }?.let(::add)
actions.firstOrNull { it is TimelineItemAction.ViewSource }?.let(::add)
actions.firstOrNull { it == TimelineItemAction.Unpin }?.let(::add)
actions.firstOrNull { it == TimelineItemAction.Forward }?.let(::add)
actions.firstOrNull { it == TimelineItemAction.ViewSource }?.let(::add)
}
}
}

View file

@ -40,9 +40,12 @@ fun TimelineItemEncryptedView(
UtdCause.UnknownDevice -> {
CommonStrings.common_unable_to_decrypt_insecure_device to CompoundDrawables.ic_compound_block
}
UtdCause.HistoricalMessage -> {
UtdCause.HistoricalMessageAndBackupIsDisabled -> {
CommonStrings.timeline_decryption_failure_historical_event_no_key_backup to CompoundDrawables.ic_compound_block
}
UtdCause.HistoricalMessageAndDeviceIsUnverified -> {
CommonStrings.timeline_decryption_failure_historical_event_unverified_device to CompoundDrawables.ic_compound_block
}
UtdCause.WithheldUnverifiedOrInsecureDevice -> {
CommonStrings.timeline_decryption_failure_withheld_unverified to CompoundDrawables.ic_compound_block
}

View file

@ -29,8 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
@Composable
fun TimelineItemEventContentView(

View file

@ -40,9 +40,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -52,6 +49,9 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@Composable

View file

@ -8,9 +8,9 @@
package io.element.android.features.messages.impl.timeline.di
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.aVoiceMessageState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
/**
* A fake [TimelineItemPresenterFactories] for screenshot tests.

View file

@ -20,7 +20,8 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
@ -32,14 +33,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua
import io.element.android.libraries.matrix.ui.messages.reply.map
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
import java.util.Date
class TimelineItemEventFactory @AssistedInject constructor(
@Assisted private val config: TimelineItemsFactoryConfig,
private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val dateFormatter: DateFormatter,
private val permalinkParser: PermalinkParser,
) {
@AssistedFactory
@ -57,9 +57,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderProfile = currentTimelineItem.event.senderProfile
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
val sentTime = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly,
)
val senderAvatarData = AvatarData(
id = currentSender.value,
name = senderProfile.getDisambiguatedDisplayName(currentSender),
@ -78,6 +79,7 @@ class TimelineItemEventFactory @AssistedInject constructor(
isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable,
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTimeMillis = currentTimelineItem.event.timestamp,
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
@ -106,7 +108,6 @@ class TimelineItemEventFactory @AssistedInject constructor(
if (!config.computeReactions) {
return TimelineItemReactions(reactions = persistentListOf())
}
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = this.event.reactions.map { reaction ->
// Sort reactions within an aggregation by timestamp descending.
// This puts the most recent at the top, useful in cases like the
@ -121,7 +122,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
AggregatedReactionSender(
senderId = it.senderId,
timestamp = date,
sentTime = timeFormatter.format(date),
sentTime = dateFormatter.format(
it.timestamp,
DateFormatterMode.TimeOrDate,
),
)
}
.toImmutableList()
@ -157,7 +161,10 @@ class TimelineItemEventFactory @AssistedInject constructor(
url = roomMember?.avatarUrl,
size = AvatarSize.TimelineReadReceipt,
),
formattedDate = lastMessageTimestampFormatter.format(receipt.timestamp)
formattedDate = dateFormatter.format(
receipt.timestamp,
mode = DateFormatterMode.TimeOrDate,
)
)
}
.toImmutableList()

View file

@ -9,13 +9,20 @@ package io.element.android.features.messages.impl.timeline.factories.virtual
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import javax.inject.Inject
class TimelineItemDaySeparatorFactory @Inject constructor(private val daySeparatorFormatter: DaySeparatorFormatter) {
class TimelineItemDaySeparatorFactory @Inject constructor(
private val dateFormatter: DateFormatter,
) {
fun create(virtualItem: VirtualTimelineItem.DayDivider): TimelineItemVirtualModel {
val formattedDate = daySeparatorFormatter.format(virtualItem.timestamp)
val formattedDate = dateFormatter.format(
timestamp = virtualItem.timestamp,
mode = DateFormatterMode.Day,
useRelative = true,
)
return TimelineItemDaySeparatorModel(
formattedDate = formattedDate
)

View file

@ -71,6 +71,7 @@ sealed interface TimelineItem {
val senderProfile: ProfileTimelineDetails,
val senderAvatar: AvatarData,
val content: TimelineItemEventContent,
val sentTimeMillis: Long = 0L,
val sentTime: String = "",
val isMine: Boolean = false,
val isEditable: Boolean,

View file

@ -36,7 +36,13 @@ open class TimelineItemEncryptedContentProvider : PreviewParameterProvider<Timel
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.HistoricalMessage,
utdCause = UtdCause.HistoricalMessageAndBackupIsDisabled,
)
),
aTimelineItemEncryptedContent(
data = UnableToDecryptContent.Data.MegolmV1AesSha2(
sessionId = "sessionId",
utdCause = UtdCause.HistoricalMessageAndDeviceIsUnverified,
)
),
aTimelineItemEncryptedContent(

View file

@ -1,23 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages
internal sealed class VoiceMessageException : Exception() {
data class FileException(
override val message: String?,
override val cause: Throwable? = null
) : VoiceMessageException()
data class PermissionMissing(
override val message: String?,
override val cause: Throwable?
) : VoiceMessageException()
data class PlayMessageError(
override val message: String?,
override val cause: Throwable?
) : VoiceMessageException()
}

View file

@ -21,7 +21,6 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
@ -29,6 +28,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService

View file

@ -1,13 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
}

View file

@ -1,103 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.mxc.MxcTools
import java.io.File
/**
* Fetches the media file for a voice message.
*
* Media is downloaded from the rust sdk and stored in the application's cache directory.
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
* Whenever a given mxc is found in the cache, it is returned immediately.
*/
interface VoiceMessageMediaRepo {
/**
* Factory for [VoiceMessageMediaRepo].
*/
fun interface Factory {
/**
* Creates a [VoiceMessageMediaRepo].
*
* @param mediaSource the media source of the voice message.
* @param mimeType the mime type of the voice message.
* @param filename the filename of the voice message.
*/
fun create(
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
): VoiceMessageMediaRepo
}
/**
* Returns the voice message media file.
*
* In case of a cache hit the file is returned immediately.
* In case of a cache miss the file is downloaded and then returned.
*
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
*/
suspend fun getMediaFile(): Result<File>
}
class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
@CacheDirectory private val cacheDir: File,
mxcTools: MxcTools,
private val matrixMediaLoader: MatrixMediaLoader,
@Assisted private val mediaSource: MediaSource,
@Assisted("mimeType") private val mimeType: String?,
@Assisted("filename") private val filename: String?,
) : VoiceMessageMediaRepo {
@ContributesBinding(RoomScope::class)
@AssistedFactory
fun interface Factory : VoiceMessageMediaRepo.Factory {
override fun create(
mediaSource: MediaSource,
@Assisted("mimeType") mimeType: String?,
@Assisted("filename") filename: String?,
): DefaultVoiceMessageMediaRepo
}
override suspend fun getMediaFile(): Result<File> = when {
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
cachedFile.exists() -> Result.success(cachedFile)
else -> matrixMediaLoader.downloadMediaFile(
source = mediaSource,
mimeType = mimeType,
filename = filename,
).mapCatching {
it.use { mediaFile ->
val dest = cachedFile.apply { parentFile?.mkdirs() }
if (mediaFile.persist(dest.path)) {
dest
} else {
error("Failed to move file to cache.")
}
}
}
}
private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let {
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
}
}
/**
* Subdirectory of the application's cache directory where voice messages are stored.
*/
private const val CACHE_VOICE_SUBDIR = "temp/voice"

View file

@ -1,225 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import java.io.File
import javax.inject.Inject
/**
* A media player specialized in playing a single voice message.
*/
interface VoiceMessagePlayer {
fun interface Factory {
/**
* Creates a [VoiceMessagePlayer].
*
* NB: Different voice messages can use the same content uri (e.g. in case of
* a forward of a voice message),
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
* a voice message. This is why we must provide the eventId as well.
*
* @param eventId The eventId of the voice message event.
* @param mediaSource The media source of the voice message.
* @param mimeType The mime type of the voice message.
* @param filename The filename of the voice message.
*/
fun create(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
): VoiceMessagePlayer
}
/**
* The current state of this player.
*/
val state: Flow<State>
/**
* Acquires control of the underlying [MediaPlayer] and prepares it
* to play the media file.
*
* Will suspend whilst the media file is being downloaded and/or
* the underlying [MediaPlayer] is loading the media file.
*/
suspend fun prepare(): Result<Unit>
/**
* Play the media.
*/
fun play()
/**
* Pause playback.
*/
fun pause()
/**
* Seek to a specific position.
*
* @param positionMs The position in milliseconds.
*/
fun seekTo(positionMs: Long)
data class State(
/**
* Whether the player is ready to play.
*/
val isReady: Boolean,
/**
* Whether this player is currently playing.
*/
val isPlaying: Boolean,
/**
* Whether the player has reached the end of the media.
*/
val isEnded: Boolean,
/**
* The elapsed time of this player in milliseconds.
*/
val currentPosition: Long,
/**
* The duration of the current content, if available.
*/
val duration: Long?,
)
}
/**
* An implementation of [VoiceMessagePlayer] which is backed by a
* [VoiceMessageMediaRepo] to fetch and cache the media file and
* which uses a global [MediaPlayer] instance to play the media.
*/
class DefaultVoiceMessagePlayer(
private val mediaPlayer: MediaPlayer,
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
private val eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
) : VoiceMessagePlayer {
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
class Factory @Inject constructor(
private val mediaPlayer: MediaPlayer,
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
) : VoiceMessagePlayer.Factory {
override fun create(
eventId: EventId?,
mediaSource: MediaSource,
mimeType: String?,
filename: String?,
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
mediaPlayer = mediaPlayer,
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
eventId = eventId,
mediaSource = mediaSource,
mimeType = mimeType,
filename = filename,
)
}
private val repo = voiceMessageMediaRepoFactory.create(
mediaSource = mediaSource,
mimeType = mimeType,
filename = filename,
)
private var internalState = MutableStateFlow(
VoiceMessagePlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
override val state: Flow<VoiceMessagePlayer.State> = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState ->
if (mediaPlayerState.isMyTrack) {
this.internalState.update {
it.copy(
isReady = mediaPlayerState.isReady,
isPlaying = mediaPlayerState.isPlaying,
isEnded = mediaPlayerState.isEnded,
currentPosition = mediaPlayerState.currentPosition,
duration = mediaPlayerState.duration,
)
}
} else {
this.internalState.update {
it.copy(
isReady = false,
isPlaying = false,
)
}
}
VoiceMessagePlayer.State(
isReady = internalState.isReady,
isPlaying = internalState.isPlaying,
isEnded = internalState.isEnded,
currentPosition = internalState.currentPosition,
duration = internalState.duration,
)
}.distinctUntilChanged()
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
repo.getMediaFile().mapCatching<Unit, File> { mediaFile ->
val state = internalState.value
mediaPlayer.setMedia(
uri = mediaFile.path,
mediaId = eventId.value,
// Files in the voice cache have no extension so we need to set the mime type manually.
mimeType = MimeTypes.Ogg,
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
)
}
} else {
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
}
override fun play() {
if (inControl()) {
mediaPlayer.play()
}
}
override fun pause() {
if (inControl()) {
mediaPlayer.pause()
}
}
override fun seekTo(positionMs: Long) {
if (inControl()) {
mediaPlayer.seekTo(positionMs)
} else {
internalState.update {
it.copy(currentPosition = positionMs)
}
}
}
private val MediaPlayer.State.isMyTrack: Boolean
get() = if (eventId == null) false else this.mediaId == eventId.value
private fun inControl(): Boolean = mediaPlayer.state.value.let {
it.isMyTrack && (it.isReady || it.isEnded)
}
}

View file

@ -8,11 +8,6 @@
package io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
@ -23,17 +18,10 @@ import dagger.multibindings.IntoMap
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
@Module
@ContributesTo(RoomScope::class)
@ -45,9 +33,7 @@ interface VoiceMessagePresenterModule {
}
class VoiceMessagePresenter @AssistedInject constructor(
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
private val analyticsService: AnalyticsService,
private val scope: CoroutineScope,
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
@Assisted private val content: TimelineItemVoiceContent,
) : Presenter<VoiceMessageState> {
@AssistedFactory
@ -55,97 +41,16 @@ class VoiceMessagePresenter @AssistedInject constructor(
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
}
private val player = voiceMessagePlayerFactory.create(
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
eventId = content.eventId,
mediaSource = content.mediaSource,
mimeType = content.mimeType,
filename = content.filename,
duration = content.duration,
)
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
@Composable
override fun present(): VoiceMessageState {
val playerState by player.state.collectAsState(
VoiceMessagePlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
currentPosition = 0L,
duration = null
)
)
val button by remember {
derivedStateOf {
when {
content.eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
}
}
}
val duration by remember {
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
}
val progress by remember {
derivedStateOf {
playerState.currentPosition / duration.toFloat()
}
}
val time by remember {
derivedStateOf {
when {
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
playerState.currentPosition > 0 -> playerState.currentPosition
else -> duration
}.milliseconds.formatShort()
}
}
val showCursor by remember {
derivedStateOf {
!play.value.isUninitialized() && !playerState.isEnded
}
}
fun eventSink(event: VoiceMessageEvents) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
player.play()
} else {
scope.launch {
play.runUpdatingState(
errorTransform = {
analyticsService.trackError(
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
)
it
},
) {
player.prepare().flatMap {
runCatching { player.play() }
}
}
}
}
}
is VoiceMessageEvents.Seek -> {
player.seekTo((event.percentage * duration).toLong())
}
}
}
return VoiceMessageState(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = { eventSink(it) },
)
return presenter.present()
}
}

View file

@ -1,24 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
data class VoiceMessageState(
val button: Button,
val progress: Float,
val time: String,
val showCursor: Boolean,
val eventSink: (event: VoiceMessageEvents) -> Unit,
) {
enum class Button {
Play,
Pause,
Downloading,
Retry,
Disabled,
}
}

View file

@ -1,56 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
override val values: Sequence<VoiceMessageState>
get() = sequenceOf(
aVoiceMessageState(
VoiceMessageState.Button.Downloading,
progress = 0f,
time = "0:00",
),
aVoiceMessageState(
VoiceMessageState.Button.Retry,
progress = 0.5f,
time = "0:01",
),
aVoiceMessageState(
VoiceMessageState.Button.Play,
progress = 1f,
time = "1:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Pause,
progress = 0.2f,
time = "10:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Disabled,
progress = 0.2f,
time = "30:00",
),
)
}
fun aVoiceMessageState(
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
progress: Float = 0f,
time: String = "1:00",
showCursor: Boolean = false,
) = VoiceMessageState(
button = button,
progress = progress,
time = time,
showCursor = showCursor,
eventSink = {},
)

View file

@ -467,7 +467,7 @@ class MessagesPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent(content = aTimelineItemPollContent())))
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditPoll, aMessageEvent(content = aTimelineItemPollContent())))
awaitItem()
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}

View file

@ -327,6 +327,7 @@ class MessagesViewTest {
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = "",
displayEmojiReactions = true,
actions = persistentListOf(TimelineItemAction.Edit),
verifiedUserSendFailure = VerifiedUserSendFailure.None,
@ -399,6 +400,7 @@ class MessagesViewTest {
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = "",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(TimelineItemAction.Edit),
@ -427,6 +429,7 @@ class MessagesViewTest {
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
sentTimeFull = "",
displayEmojiReactions = true,
verifiedUserSendFailure = aChangedIdentitySendFailure(),
actions = persistentListOf(),

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -86,6 +87,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@ -128,6 +130,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@ -170,13 +173,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@ -215,13 +219,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@ -263,12 +268,13 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@ -308,13 +314,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@ -355,13 +362,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@ -403,14 +411,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@ -448,14 +457,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@ -496,14 +506,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
)
@ -542,14 +553,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.AddCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -592,6 +604,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@ -599,8 +612,8 @@ class ActionListPresenterTest {
TimelineItemAction.Forward,
// Not here
// TimelineItemAction.AddCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
@ -641,14 +654,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.EditCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,
@ -691,13 +705,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
@ -738,6 +753,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = stateEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@ -808,14 +824,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.CopyText,
TimelineItemAction.Redact,
)
@ -855,13 +872,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@ -909,14 +927,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Unpin,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.CopyLink,
TimelineItemAction.Unpin,
TimelineItemAction.CopyText,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
@ -1006,6 +1025,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@ -1046,14 +1066,15 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.EditPoll,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@ -1089,13 +1110,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.EndPoll,
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@ -1131,12 +1153,13 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@ -1174,13 +1197,14 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.Redact,
)
)
@ -1214,6 +1238,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
@ -1268,6 +1293,7 @@ private fun createActionListPresenter(
initialState = mapOf(
FeatureFlags.MediaCaptionCreation.key to allowCaption,
),
)
),
dateFormatter = FakeDateFormatter(),
)
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.actionlist.model
import org.junit.Test
class TimelineItemActionComparatorTest {
@Test
fun `check that the list in the comparator only contain each item once`() {
val sut = TimelineItemActionComparator()
sut.orderedList.forEach {
require(sut.orderedList.count { item -> item == it } == 1, { "Duplicate ${it::class.java}.$it" })
}
}
@Test
fun `check that the list in the comparator contains all the items`() {
val sut = TimelineItemActionComparator()
TimelineItemAction.entries.forEach {
require(it in sut.orderedList, { "Missing ${it::class.simpleName}.$it in orderedList" })
}
}
}

View file

@ -28,8 +28,7 @@ import io.element.android.features.messages.impl.utils.FakeTextPillificationHelp
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -80,7 +79,7 @@ internal fun TestScope.aTimelineItemsFactory(
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
),
matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
dateFormatter = FakeDateFormatter(),
permalinkParser = FakePermalinkParser(),
config = config
)
@ -88,7 +87,7 @@ internal fun TestScope.aTimelineItemsFactory(
},
virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory(
FakeDaySeparatorFormatter()
FakeDateFormatter()
),
),
timelineItemGrouper = TimelineItemGrouper(),

View file

@ -27,24 +27,7 @@ class PinnedMessagesListTimelineActionPostProcessorTest {
fun `ensure that some actions are kept and some other are filtered out`() {
val sut = PinnedMessagesListTimelineActionPostProcessor()
val result = sut.process(
listOf(
TimelineItemAction.Forward,
TimelineItemAction.CopyText,
TimelineItemAction.CopyCaption,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact,
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread,
TimelineItemAction.Edit,
TimelineItemAction.EditCaption,
TimelineItemAction.AddCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.EndPoll,
TimelineItemAction.Pin,
TimelineItemAction.Unpin,
)
TimelineItemAction.entries.toList()
)
assertThat(result).isEqualTo(
listOf(

View file

@ -18,7 +18,6 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
@ -36,6 +35,7 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule

View file

@ -1,151 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.mxc.MxcTools
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class DefaultVoiceMessageMediaRepoTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `cache miss - downloads and returns cached file successfully`() = runTest {
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
path = temporaryFolder.createRustMediaFile().path
}
val repo = createDefaultVoiceMessageMediaRepo(
temporaryFolder = temporaryFolder,
matrixMediaLoader = matrixMediaLoader,
)
repo.getMediaFile().let { result ->
assertThat(result.isSuccess).isTrue()
result.getOrThrow().let { file ->
assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
assertThat(file.exists()).isTrue()
}
}
}
@Test
fun `cache miss - download fails`() = runTest {
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
shouldFail = true
}
val repo = createDefaultVoiceMessageMediaRepo(
temporaryFolder = temporaryFolder,
matrixMediaLoader = matrixMediaLoader,
)
repo.getMediaFile().let { result ->
assertThat(result.isFailure).isTrue()
result.exceptionOrNull()!!.let { exception ->
assertThat(exception).isInstanceOf(RuntimeException::class.java)
}
}
}
@Test
fun `cache miss - download succeeds but file move fails`() = runTest {
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
path = temporaryFolder.createRustMediaFile().path
}
File(temporaryFolder.cachedFilePath).apply {
parentFile?.mkdirs()
// Deny access to parent folder so move to cache will fail.
parentFile?.setReadable(false)
parentFile?.setWritable(false)
parentFile?.setExecutable(false)
}
val repo = createDefaultVoiceMessageMediaRepo(
temporaryFolder = temporaryFolder,
matrixMediaLoader = matrixMediaLoader,
)
repo.getMediaFile().let { result ->
assertThat(result.isFailure).isTrue()
result.exceptionOrNull()?.let { exception ->
assertThat(exception).apply {
isInstanceOf(IllegalStateException::class.java)
hasMessageThat().isEqualTo("Failed to move file to cache.")
}
}
}
}
@Test
fun `cache hit - returns cached file successfully`() = runTest {
temporaryFolder.createCachedFile()
val matrixMediaLoader = FakeMatrixMediaLoader().apply {
shouldFail = true // so that if we hit the media loader it will crash
}
val repo = createDefaultVoiceMessageMediaRepo(
temporaryFolder = temporaryFolder,
matrixMediaLoader = matrixMediaLoader,
)
repo.getMediaFile().let { result ->
assertThat(result.isSuccess).isTrue()
result.getOrThrow().let { file ->
assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
assertThat(file.exists()).isTrue()
}
}
}
@Test
fun `invalid mxc uri returns a failure`() = runTest {
val repo = createDefaultVoiceMessageMediaRepo(
temporaryFolder = temporaryFolder,
mxcUri = INVALID_MXC_URI,
)
repo.getMediaFile().let { result ->
assertThat(result.isFailure).isTrue()
result.exceptionOrNull()!!.let { exception ->
assertThat(exception).isInstanceOf(RuntimeException::class.java)
assertThat(exception).hasMessageThat().isEqualTo("Invalid mxcUri.")
}
}
}
}
private fun createDefaultVoiceMessageMediaRepo(
temporaryFolder: TemporaryFolder,
matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
mxcUri: String = MXC_URI,
) = DefaultVoiceMessageMediaRepo(
cacheDir = temporaryFolder.root,
mxcTools = MxcTools(),
matrixMediaLoader = matrixMediaLoader,
mediaSource = MediaSource(
url = mxcUri,
json = null
),
mimeType = MimeTypes.Ogg,
filename = "someBody.ogg"
)
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
private const val INVALID_MXC_URI = "notAnMxcUri"
private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
parentFile?.mkdirs()
createNewFile()
}
private fun TemporaryFolder.createRustMediaFile() = File(this.root, "rustMediaFile.ogg").apply { createNewFile() }

View file

@ -1,307 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultVoiceMessagePlayerTest {
@Test
fun `initial state`() = runTest {
createDefaultVoiceMessagePlayer().state.test {
matchInitialState()
}
}
@Test
fun `prepare succeeds`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
assertThat(player.prepare().isSuccess).isTrue()
matchReadyState()
}
}
@Test
fun `prepare fails when repo fails`() = runTest {
val player = createDefaultVoiceMessagePlayer(
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
shouldFail = true
},
)
player.state.test {
matchInitialState()
assertThat(player.prepare().isFailure).isTrue()
}
}
@Test
fun `prepare fails with no eventId`() = runTest {
val player = createDefaultVoiceMessagePlayer(
eventId = null
)
player.state.test {
matchInitialState()
assertThat(player.prepare().isFailure).isTrue()
}
}
@Test
fun `play after prepare works`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
assertThat(player.prepare().isSuccess).isTrue()
matchReadyState()
player.play()
awaitItem().let {
assertThat(it.isPlaying).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
}
}
}
@Test
fun `play reaches end of media`() = runTest {
val player = createDefaultVoiceMessagePlayer(
mediaPlayer = FakeMediaPlayer(
fakeTotalDurationMs = 1000,
fakePlayedDurationMs = 1000
)
)
player.state.test {
matchInitialState()
assertThat(player.prepare().isSuccess).isTrue()
matchReadyState(fakeTotalDurationMs = 1000)
player.play()
awaitItem().let {
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
assertThat(it.duration).isEqualTo(1000)
}
}
}
@Test
fun `player1 plays again after both player1 and player2 are finished`() = runTest {
val mediaPlayer = FakeMediaPlayer(
fakeTotalDurationMs = 1_000L,
fakePlayedDurationMs = 1_000L,
)
val player1 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
val player2 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer)
// Play player1 until the end.
player1.state.test {
matchInitialState()
assertThat(player1.prepare().isSuccess).isTrue()
matchReadyState(1_000L)
player1.play()
awaitItem().let { // it plays until the end.
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
assertThat(it.duration).isEqualTo(1000)
}
}
// Play player2 until the end.
player2.state.test {
matchInitialState()
assertThat(player2.prepare().isSuccess).isTrue()
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
assertThat(it.duration).isEqualTo(1000)
}
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(0)
assertThat(it.duration).isNull()
}
matchReadyState(1_000L)
player2.play()
awaitItem().let { // it plays until the end.
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
assertThat(it.duration).isEqualTo(1000)
}
}
// Play player1 again.
player1.state.test {
awaitItem().let { // Last previous state/
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
assertThat(it.duration).isEqualTo(1000)
}
assertThat(player1.prepare().isSuccess).isTrue()
awaitItem().let { // Additional spurious state due to MediaPlayer owner change.
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(0)
assertThat(it.duration).isNull()
}
matchReadyState(1_000L)
player1.play()
awaitItem().let { // it played again until the end.
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isTrue()
assertThat(it.currentPosition).isEqualTo(1000)
assertThat(it.duration).isEqualTo(1000)
}
}
}
@Test
fun `pause after play pauses`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
assertThat(player.prepare().isSuccess).isTrue()
matchReadyState()
player.play()
skipItems(1) // skip play state
player.pause()
awaitItem().let {
assertThat(it.isPlaying).isFalse()
assertThat(it.currentPosition).isEqualTo(1000)
}
}
}
@Test
fun `play after pause works`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
assertThat(player.prepare().isSuccess).isTrue()
matchReadyState()
player.play()
skipItems(1) // skip play state
player.pause()
skipItems(1) // skip pause state
player.play()
awaitItem().let {
assertThat(it.isPlaying).isTrue()
assertThat(it.currentPosition).isEqualTo(2000)
}
}
}
@Test
fun `seek before prepare works`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
player.seekTo(2000)
awaitItem().let {
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(2000)
assertThat(it.duration).isNull()
}
assertThat(player.prepare().isSuccess).isTrue()
awaitItem().let {
assertThat(it.isReady).isTrue()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(2000)
assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
}
}
}
@Test
fun `seek after prepare works`() = runTest {
val player = createDefaultVoiceMessagePlayer()
player.state.test {
matchInitialState()
assertThat(player.prepare().isSuccess).isTrue()
matchReadyState()
player.seekTo(2000)
awaitItem().let {
assertThat(it.isReady).isTrue()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(2000)
assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS)
}
}
}
}
private const val FAKE_TOTAL_DURATION_MS = 10_000L
private const val FAKE_PLAYED_DURATION_MS = 1000L
private fun createDefaultVoiceMessagePlayer(
mediaPlayer: MediaPlayer = FakeMediaPlayer(
fakeTotalDurationMs = FAKE_TOTAL_DURATION_MS,
fakePlayedDurationMs = FAKE_PLAYED_DURATION_MS
),
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
eventId: EventId? = AN_EVENT_ID,
) = DefaultVoiceMessagePlayer(
mediaPlayer = mediaPlayer,
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
eventId = eventId,
mediaSource = MediaSource(
url = MXC_URI,
json = null
),
mimeType = MimeTypes.Ogg,
filename = "someBody.ogg"
)
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.matchInitialState() {
awaitItem().let {
assertThat(it.isReady).isFalse()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(0)
assertThat(it.duration).isNull()
}
}
private suspend fun TurbineTestContext<VoiceMessagePlayer.State>.matchReadyState(
fakeTotalDurationMs: Long = FAKE_TOTAL_DURATION_MS,
) {
awaitItem().let {
assertThat(it.isReady).isTrue()
assertThat(it.isPlaying).isFalse()
assertThat(it.isEnded).isFalse()
assertThat(it.currentPosition).isEqualTo(0)
assertThat(it.duration).isEqualTo(fakeTotalDurationMs)
}
}

View file

@ -1,26 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import io.element.android.tests.testutils.simulateLongTask
import java.io.File
/**
* A fake implementation of [VoiceMessageMediaRepo] for testing purposes.
*/
class FakeVoiceMessageMediaRepo : VoiceMessageMediaRepo {
var shouldFail = false
override suspend fun getMediaFile(): Result<File> = simulateLongTask {
if (shouldFail) {
Result.failure(IllegalStateException("Failed to get media file"))
} else {
Result.success(File(""))
}
}
}

View file

@ -1,242 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.voicemessages.timeline
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class VoiceMessagePresenterTest {
@Test
fun `initial state has proper default values`() = runTest {
val presenter = createVoiceMessagePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `pressing play downloads and plays`() = runTest {
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:00")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
}
}
@Test
fun `pressing play downloads and fails`() = runTest {
val analyticsService = FakeAnalyticsService()
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
analyticsService = analyticsService,
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
analyticsService.trackedErrors.first().also {
assertThat(it).apply {
isInstanceOf(VoiceMessageException.PlayMessageError::class.java)
hasMessageThat().isEqualTo("Error while trying to play voice message")
}
}
}
}
@Test
fun `pressing pause while playing pauses`() = runTest {
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
skipItems(2) // skip downloading states
val playingState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
playingState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
}
}
@Test
fun `content with null eventId shows disabled button`() = runTest {
val presenter = createVoiceMessagePresenter(
content = aTimelineItemVoiceContent(eventId = null),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `seeking before play`() = runTest {
val presenter = createVoiceMessagePresenter(
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:05")
}
}
}
@Test
fun `seeking after play`() = runTest {
val presenter = createVoiceMessagePresenter(
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
skipItems(2) // skip downloading states
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.progress).isEqualTo(0.1f)
assertThat(it.time).isEqualTo("0:01")
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:05")
}
}
}
}
fun TestScope.createVoiceMessagePresenter(
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
) = VoiceMessagePresenter(
voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, filename ->
DefaultVoiceMessagePlayer(
mediaPlayer = mediaPlayer,
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
eventId = eventId,
mediaSource = mediaSource,
mimeType = mimeType,
filename = filename
)
},
analyticsService = analyticsService,
scope = this,
content = content,
)

View file

@ -9,7 +9,8 @@ package io.element.android.features.poll.impl.history.model
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toPersistentList
@ -18,7 +19,7 @@ import javax.inject.Inject
class PollHistoryItemsFactory @Inject constructor(
private val pollContentStateFactory: PollContentStateFactory,
private val daySeparatorFormatter: DaySeparatorFormatter,
private val dateFormatter: DateFormatter,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun create(timelineItems: List<MatrixTimelineItem>): PollHistoryItems = withContext(dispatchers.computation) {
@ -45,7 +46,11 @@ class PollHistoryItemsFactory @Inject constructor(
val pollContent = timelineItem.event.content as? PollContent ?: return null
val pollContentState = pollContentStateFactory.create(timelineItem.event, pollContent)
PollHistoryItem(
formattedDate = daySeparatorFormatter.format(timelineItem.event.timestamp),
formattedDate = dateFormatter.format(
timestamp = timelineItem.event.timestamp,
mode = DateFormatterMode.Day,
useRelative = true
),
state = pollContentState
)
}

View file

@ -21,7 +21,7 @@ import io.element.android.features.poll.impl.history.model.PollHistoryItemsFacto
import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -161,7 +161,7 @@ class PollHistoryPresenterTest {
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory(
pollContentStateFactory = DefaultPollContentStateFactory(FakeMatrixClient()),
daySeparatorFormatter = FakeDaySeparatorFormatter(),
dateFormatter = FakeDateFormatter(),
dispatchers = testCoroutineDispatchers(),
),
): PollHistoryPresenter {

View file

@ -23,6 +23,7 @@ import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -63,7 +64,7 @@ class DeveloperSettingsPresenter @Inject constructor(
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
}
val clearCacheAction = remember {
mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val customElementCallBaseUrl by appPreferencesStore
.getCustomElementCallBaseUrlFlow()
@ -94,7 +95,7 @@ class DeveloperSettingsPresenter @Inject constructor(
val featureUiModels = createUiModels(features, enabledFeatures)
val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value) {
LaunchedEffect(clearCacheAction.value.isSuccess()) {
computeCacheSize(cacheSize)
}
@ -180,7 +181,7 @@ class DeveloperSettingsPresenter @Inject constructor(
}.runCatchingUpdatingState(cacheSize)
}
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncData<Unit>>) = launch {
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch {
suspend {
clearCacheUseCase()
}.runCatchingUpdatingState(clearCacheAction)

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.developer
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import kotlinx.collections.immutable.ImmutableList
@ -16,7 +17,7 @@ data class DeveloperSettingsState(
val features: ImmutableList<FeatureUiModel>,
val cacheSize: AsyncData<String>,
val rageshakeState: RageshakePreferencesState,
val clearCacheAction: AsyncData<Unit>,
val clearCacheAction: AsyncAction<Unit>,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
val isSimpleSlidingSyncEnabled: Boolean,
val hideImagesAndVideos: Boolean,

View file

@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.developer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
@ -17,7 +18,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
get() = sequenceOf(
aDeveloperSettingsState(),
aDeveloperSettingsState(
clearCacheAction = AsyncData.Loading()
clearCacheAction = AsyncAction.Loading
),
aDeveloperSettingsState(
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
@ -28,7 +29,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
}
fun aDeveloperSettingsState(
clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized,
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
isSimplifiedSlidingSyncEnabled: Boolean = false,
hideImagesAndVideos: Boolean = false,

View file

@ -25,6 +25,7 @@ import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@ -33,7 +34,6 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.DeviceId
@ -270,7 +270,7 @@ private fun ColumnScope.Footer(
private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_developer_options)) },
leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_developer_options)),
leadingContent = ListItemContent.Icon(IconSource.Resource(CompoundDrawables.ic_compound_code)),
onClick = onOpenDeveloperSettings
)
}

View file

@ -5,17 +5,17 @@
* Please see LICENSE in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.preferences.impl.developer
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@ -24,8 +24,8 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
@ -38,37 +38,29 @@ class DeveloperSettingsPresenterTest {
val warmUpRule = WarmUpRule()
@Test
fun `present - ensures initial state is correct`() = runTest {
fun `present - ensures initial states are correct`() = runTest {
val presenter = createDeveloperSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.features).isEmpty()
assertThat(initialState.clearCacheAction).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.cacheSize).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
assertThat(initialState.hideImagesAndVideos).isFalse()
val loadedState = awaitItem()
assertThat(loadedState.rageshakeState.isEnabled).isFalse()
assertThat(loadedState.rageshakeState.isSupported).isTrue()
assertThat(loadedState.rageshakeState.sensitivity).isEqualTo(0.3f)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ensures feature list is loaded`() = runTest {
val presenter = createDeveloperSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = awaitLastSequentialItem()
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
cancelAndIgnoreRemainingEvents()
presenter.test {
awaitItem().also { state ->
assertThat(state.features).isEmpty()
assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized)
assertThat(state.customElementCallBaseUrlState).isNotNull()
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
assertThat(state.hideImagesAndVideos).isFalse()
assertThat(state.rageshakeState.isEnabled).isFalse()
assertThat(state.rageshakeState.isSupported).isTrue()
assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f)
}
awaitItem().also { state ->
assertThat(state.features).isNotEmpty()
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
}
awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
}
}
}
@ -76,30 +68,28 @@ class DeveloperSettingsPresenterTest {
fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest {
val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay")
val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = awaitLastSequentialItem()
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
cancelAndIgnoreRemainingEvents()
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch)
}
}
}
@Test
fun `present - ensures state is updated when enabled feature event is triggered`() = runTest {
val presenter = createDeveloperSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val stateBeforeEvent = awaitItem()
val featureBeforeEvent = stateBeforeEvent.features.first()
stateBeforeEvent.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(featureBeforeEvent, !featureBeforeEvent.isEnabled))
val stateAfterEvent = awaitItem()
val featureAfterEvent = stateAfterEvent.features.first()
assertThat(featureBeforeEvent.key).isEqualTo(featureAfterEvent.key)
assertThat(featureBeforeEvent.isEnabled).isNotEqualTo(featureAfterEvent.isEnabled)
cancelAndIgnoreRemainingEvents()
presenter.test {
skipItems(2)
awaitItem().also { state ->
val feature = state.features.first()
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled))
}
awaitItem().also { state ->
val feature = state.features.first()
assertThat(feature.isEnabled).isTrue()
assertThat(feature.key).isEqualTo(feature.key)
}
}
}
@ -107,19 +97,25 @@ class DeveloperSettingsPresenterTest {
fun `present - clear cache`() = runTest {
val clearCacheUseCase = FakeClearCacheUseCase()
val presenter = createDeveloperSettingsPresenter(clearCacheUseCase = clearCacheUseCase)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
presenter.test {
skipItems(2)
assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse()
initialState.eventSink(DeveloperSettingsEvents.ClearCache)
val stateAfterEvent = awaitItem()
assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(AsyncData.Loading::class.java)
skipItems(1)
assertThat(awaitItem().clearCacheAction).isInstanceOf(AsyncData.Success::class.java)
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
cancelAndIgnoreRemainingEvents()
awaitItem().also { state ->
state.eventSink(DeveloperSettingsEvents.ClearCache)
}
awaitItem().also { state ->
assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
}
awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Loading::class.java)
}
awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
}
}
}
@ -127,26 +123,25 @@ class DeveloperSettingsPresenterTest {
fun `present - custom element call base url`() = runTest {
val preferencesStore = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.customElementCallBaseUrlState.baseUrl).isNull()
initialState.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
val updatedItem = awaitItem()
assertThat(updatedItem.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
assertThat(updatedItem.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.customElementCallBaseUrlState.baseUrl).isNull()
state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy"))
}
awaitItem().also { state ->
assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy")
assertThat(state.customElementCallBaseUrlState.defaultUrl).isEqualTo(ElementCallConfig.DEFAULT_BASE_URL)
}
}
}
@Test
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
val presenter = createDeveloperSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val urlValidator = awaitLastSequentialItem().customElementCallBaseUrlState.validator
presenter.test {
skipItems(2)
val urlValidator = awaitItem().customElementCallBaseUrlState.validator
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
assertThat(urlValidator("test")).isFalse()
assertThat(urlValidator("http://")).isFalse()
@ -155,30 +150,31 @@ class DeveloperSettingsPresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest {
val logoutCallRecorder = lambdaRecorder<Boolean, String?> { "" }
val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder)
val preferences = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.isSimpleSlidingSyncEnabled).isFalse()
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isTrue()
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
advanceUntilIdle()
logoutCallRecorder.assertions().isCalledOnce()
initialState.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
assertThat(awaitItem().isSimpleSlidingSyncEnabled).isFalse()
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
advanceUntilIdle()
logoutCallRecorder.assertions().isCalledExactly(times = 2)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(true))
}
awaitItem().also { state ->
assertThat(state.isSimpleSlidingSyncEnabled).isTrue()
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isTrue()
advanceUntilIdle()
logoutCallRecorder.assertions().isCalledOnce()
state.eventSink(DeveloperSettingsEvents.SetSimplifiedSlidingSyncEnabled(false))
}
awaitItem().also { state ->
assertThat(state.isSimpleSlidingSyncEnabled).isFalse()
assertThat(preferences.isSimplifiedSlidingSyncEnabledFlow().first()).isFalse()
advanceUntilIdle()
logoutCallRecorder.assertions().isCalledExactly(2)
}
}
}
@ -186,17 +182,21 @@ class DeveloperSettingsPresenterTest {
fun `present - toggling hide image and video`() = runTest {
val preferences = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.hideImagesAndVideos).isFalse()
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
assertThat(awaitItem().hideImagesAndVideos).isTrue()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
initialState.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
assertThat(awaitItem().hideImagesAndVideos).isFalse()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.hideImagesAndVideos).isFalse()
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(true))
}
awaitItem().also { state ->
assertThat(state.hideImagesAndVideos).isTrue()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isTrue()
state.eventSink(DeveloperSettingsEvents.SetHideImagesAndVideos(false))
}
awaitItem().also { state ->
assertThat(state.hideImagesAndVideos).isFalse()
assertThat(preferences.doesHideImagesAndVideosFlow().first()).isFalse()
}
}
}

View file

@ -57,6 +57,7 @@
<string name="screen_room_details_requests_to_join_title">"Žádosti o vstup"</string>
<string name="screen_room_details_roles_and_permissions">"Role a oprávnění"</string>
<string name="screen_room_details_room_name_label">"Název místnosti"</string>
<string name="screen_room_details_security_and_privacy_title">"Zabezpečení a soukromí"</string>
<string name="screen_room_details_security_title">"Zabezpečení"</string>
<string name="screen_room_details_share_room_title">"Sdílet místnost"</string>
<string name="screen_room_details_title">"Informace o místnosti"</string>

View file

@ -49,6 +49,7 @@
<string name="screen_room_details_invite_people_title">"Πρόσκληση ατόμων"</string>
<string name="screen_room_details_leave_conversation_title">"Αποχώρηση από τη συζήτηση"</string>
<string name="screen_room_details_leave_room_title">"Αποχώρηση από το δωμάτιο"</string>
<string name="screen_room_details_media_gallery_title">"Πολυμέσα και αρχεία"</string>
<string name="screen_room_details_notification_mode_custom">"Προσαρμοσμένο"</string>
<string name="screen_room_details_notification_mode_default">"Προεπιλογή"</string>
<string name="screen_room_details_notification_title">"Ειδοποιήσεις"</string>
@ -56,6 +57,7 @@
<string name="screen_room_details_requests_to_join_title">"Αιτήματα συμμετοχής"</string>
<string name="screen_room_details_roles_and_permissions">"Ρόλοι και δικαιώματα"</string>
<string name="screen_room_details_room_name_label">"Όνομα δωματίου"</string>
<string name="screen_room_details_security_and_privacy_title">"Ασφάλεια &amp; απόρρητο"</string>
<string name="screen_room_details_security_title">"Ασφάλεια"</string>
<string name="screen_room_details_share_room_title">"Κοινή χρήση δωματίου"</string>
<string name="screen_room_details_title">"Πληροφορίες δωματίου"</string>

View file

@ -49,10 +49,12 @@
<string name="screen_room_details_invite_people_title">"Kutsu ihmisiä"</string>
<string name="screen_room_details_leave_conversation_title">"Poistu keskustelusta"</string>
<string name="screen_room_details_leave_room_title">"Poistu huoneesta"</string>
<string name="screen_room_details_media_gallery_title">"Media ja tiedostot"</string>
<string name="screen_room_details_notification_mode_custom">"Mukautettu"</string>
<string name="screen_room_details_notification_mode_default">"Oletus"</string>
<string name="screen_room_details_notification_title">"Ilmoitukset"</string>
<string name="screen_room_details_pinned_events_row_title">"Kiinnitetyt viestit"</string>
<string name="screen_room_details_requests_to_join_title">"Liittymispyynnöt"</string>
<string name="screen_room_details_roles_and_permissions">"Roolit ja oikeudet"</string>
<string name="screen_room_details_room_name_label">"Huoneen nimi"</string>
<string name="screen_room_details_security_title">"Turvallisuus"</string>
@ -80,7 +82,7 @@
<string name="screen_room_member_list_manage_member_user_info">"Näytä profiili"</string>
<string name="screen_room_member_list_mode_banned">"Porttikiellot"</string>
<string name="screen_room_member_list_mode_members">"Jäsenet"</string>
<string name="screen_room_member_list_pending_header_title">"Kutsutut"</string>
<string name="screen_room_member_list_pending_header_title">"Kutsuttu"</string>
<string name="screen_room_member_list_removing_user">"Poistetaan käyttäjää %1$s huoneesta…"</string>
<string name="screen_room_member_list_role_administrator">"Ylläpitäjä"</string>
<string name="screen_room_member_list_role_moderator">"Valvoja"</string>

View file

@ -57,6 +57,7 @@
<string name="screen_room_details_requests_to_join_title">"Demandes en attente"</string>
<string name="screen_room_details_roles_and_permissions">"Rôles et autorisations"</string>
<string name="screen_room_details_room_name_label">"Nom du salon"</string>
<string name="screen_room_details_security_and_privacy_title">"Sécurité &amp; confidentialité"</string>
<string name="screen_room_details_security_title">"Sécurité"</string>
<string name="screen_room_details_share_room_title">"Partager le salon"</string>
<string name="screen_room_details_title">"Informations du salon"</string>

View file

@ -57,6 +57,7 @@
<string name="screen_room_details_requests_to_join_title">"Csatlakozási kérelem"</string>
<string name="screen_room_details_roles_and_permissions">"Szerepkörök és jogosultságok"</string>
<string name="screen_room_details_room_name_label">"Szoba neve"</string>
<string name="screen_room_details_security_and_privacy_title">"Biztonság és adatvédelem"</string>
<string name="screen_room_details_security_title">"Biztonság"</string>
<string name="screen_room_details_share_room_title">"Szoba megosztása"</string>
<string name="screen_room_details_title">"Szobainformációk"</string>

View file

@ -57,6 +57,7 @@
<string name="screen_room_details_requests_to_join_title">"Requests to join"</string>
<string name="screen_room_details_roles_and_permissions">"Roles and permissions"</string>
<string name="screen_room_details_room_name_label">"Room name"</string>
<string name="screen_room_details_security_and_privacy_title">"Security &amp; privacy"</string>
<string name="screen_room_details_security_title">"Security"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_title">"Room info"</string>

View file

@ -61,6 +61,10 @@ fun RoomListContextMenu(
onFavoriteChange = { isFavorite ->
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
onClearCacheRoomClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId))
},
)
}
}
@ -73,6 +77,7 @@ private fun RoomListModalBottomSheetContent(
onFavoriteChange: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit,
onClearCacheRoomClick: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth()
@ -177,6 +182,18 @@ private fun RoomListModalBottomSheetContent(
),
style = ListItemStyle.Destructive,
)
if (contextMenu.eventCacheFeatureFlagEnabled) {
ListItem(
headlineContent = {
Text(text = "Clear cache for this room")
},
modifier = Modifier.clickable { onClearCacheRoomClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.Delete())
),
style = ListItemStyle.Primary,
)
}
}
}
@ -195,5 +212,6 @@ internal fun RoomListModalBottomSheetContentPreview(
onRoomSettingsClick = {},
onLeaveRoomClick = {},
onFavoriteChange = {},
onClearCacheRoomClick = {},
)
}

View file

@ -25,4 +25,5 @@ sealed interface RoomListEvents {
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents
data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvents
}

View file

@ -146,6 +146,7 @@ class RoomListPresenter @Inject constructor(
AcceptDeclineInviteEvents.DeclineInvite(event.roomListRoomSummary.toInviteData())
)
}
is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
}
}
@ -255,7 +256,8 @@ class RoomListPresenter @Inject constructor(
isDm = event.roomListRoomSummary.isDm,
isFavorite = event.roomListRoomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomListRoomSummary.hasNewContent
hasNewContent = event.roomListRoomSummary.hasNewContent,
eventCacheFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.EventCache),
)
contextMenuState.value = initialState
@ -312,6 +314,12 @@ class RoomListPresenter @Inject constructor(
}
}
private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
client.getRoom(roomId)?.use { room ->
room.clearEventCacheStorage()
}
}
/**
* Checks if the user needs to migrate to a native sliding sync version.
*/

View file

@ -46,6 +46,7 @@ data class RoomListState(
val isDm: Boolean,
val isFavorite: Boolean,
val markAsUnreadFeatureFlagEnabled: Boolean,
val eventCacheFeatureFlagEnabled: Boolean,
val hasNewContent: Boolean,
) : ContextMenu
}

View file

@ -31,4 +31,5 @@ internal fun aContextMenuShown(
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
eventCacheFeatureFlagEnabled = false,
)

View file

@ -10,7 +10,8 @@ package io.element.android.features.roomlist.impl.datasource
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@ -22,7 +23,7 @@ import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor(
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val dateFormatter: DateFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
) {
fun create(roomSummary: RoomSummary): RoomListRoomSummary {
@ -36,7 +37,11 @@ class RoomListRoomSummaryFactory @Inject constructor(
numberOfUnreadMentions = roomInfo.numUnreadMentions,
numberOfUnreadNotifications = roomInfo.numUnreadNotifications,
isMarkedUnread = roomInfo.isMarkedUnread,
timestamp = lastMessageTimestampFormatter.format(roomSummary.lastMessageTimestamp),
timestamp = dateFormatter.format(
timestamp = roomSummary.lastMessageTimestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
),
lastMessage = roomSummary.lastMessage?.let { message ->
roomLastMessageFormatter.format(message.event, roomInfo.isDm)
}.orEmpty(),

View file

@ -31,9 +31,8 @@ import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
@ -188,6 +187,7 @@ class RoomListPresenterTest {
createRoomListRoomSummary(
numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2,
timestamp = "0 TimeOrDate true",
)
)
cancelAndIgnoreRemainingEvents()
@ -288,6 +288,7 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
eventCacheFeatureFlagEnabled = false,
hasNewContent = false,
)
)
@ -305,6 +306,7 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = true,
markAsUnreadFeatureFlagEnabled = true,
eventCacheFeatureFlagEnabled = false,
hasNewContent = false,
)
)
@ -335,6 +337,7 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
eventCacheFeatureFlagEnabled = false,
hasNewContent = false,
)
)
@ -633,9 +636,7 @@ class RoomListPresenterTest {
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE)
},
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
@ -652,7 +653,7 @@ class RoomListPresenterTest {
roomListDataSource = RoomListDataSource(
roomListService = client.roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter,
),
coroutineDispatchers = testCoroutineDispatchers(),

View file

@ -11,7 +11,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary
@ -30,12 +30,12 @@ class RoomListDataSourceTest {
postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
val lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter()
lastMessageTimestampFormatter.givenFormat("Today")
var dateFormatterResult = "Today"
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
dateFormatter = dateFormatter,
),
dateTimeObserver = dateTimeObserver,
)
@ -47,7 +47,7 @@ class RoomListDataSourceTest {
val initialRoomList = awaitItem()
assertThat(initialRoomList).isNotEmpty()
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
lastMessageTimestampFormatter.givenFormat("Yesterday")
dateFormatterResult = "Yesterday"
// Trigger a date change
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
// Check there is a new list and it's not the same as the previous one
@ -64,12 +64,12 @@ class RoomListDataSourceTest {
postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
val lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter()
lastMessageTimestampFormatter.givenFormat("Today")
var dateFormatterResult = "Today"
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
dateFormatter = dateFormatter,
),
dateTimeObserver = dateTimeObserver,
)
@ -80,7 +80,7 @@ class RoomListDataSourceTest {
val initialRoomList = awaitItem()
assertThat(initialRoomList).isNotEmpty()
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
lastMessageTimestampFormatter.givenFormat("Yesterday")
dateFormatterResult = "Yesterday"
// Trigger a timezone change
dateTimeObserver.given(DateTimeObserver.Event.TimeZoneChanged)
// Check there is a new list and it's not the same as the previous one

View file

@ -7,13 +7,14 @@
package io.element.android.features.roomlist.impl.datasource
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
fun aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter: LastMessageTimestampFormatter = LastMessageTimestampFormatter { _ -> "Today" },
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
roomLastMessageFormatter: RoomLastMessageFormatter = RoomLastMessageFormatter { _, _ -> "Hey" }
) = RoomListRoomSummaryFactory(
lastMessageTimestampFormatter = lastMessageTimestampFormatter,
dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter
)

View file

@ -8,7 +8,6 @@
package io.element.android.features.roomlist.impl.model
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@ -84,6 +83,7 @@ internal fun createRoomListRoomSummary(
isFavorite: Boolean = false,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
heroes: List<AvatarData> = emptyList(),
timestamp: String? = null,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
@ -92,7 +92,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadMessages = numberOfUnreadMessages,
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = A_FORMATTED_DATE,
timestamp = timestamp,
lastMessage = "",
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
displayType = displayType,

View file

@ -12,7 +12,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@ -143,7 +143,7 @@ fun TestScope.createRoomListSearchPresenter(
dataSource = RoomListSearchDataSource(
roomListService = roomListService,
roomSummaryFactory = aRoomListRoomSummaryFactory(
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
dateFormatter = FakeDateFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),

View file

@ -9,7 +9,7 @@
<string name="screen_chat_backup_key_storage_toggle_title">"Salli avainten säilytys"</string>
<string name="screen_chat_backup_recovery_action_change">"Vaihda palautusavain"</string>
<string name="screen_chat_backup_recovery_action_change_description">"Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, jos olet menettänyt kaikki nykyiset laitteesi."</string>
<string name="screen_chat_backup_recovery_action_confirm">"Käytä palautusavainta"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Syötä palautusavain"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Avainten säilytys ei ole tällä hetkellä synkronoitu."</string>
<string name="screen_chat_backup_recovery_action_setup">"Ota palautus käyttöön"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Pääset käsiksi salattuihin viesteihisi, jos menetät kaikki laitteesi tai olet kirjautunut ulos %1$s -sovelluksesta kaikkialla."</string>

View file

@ -12,7 +12,7 @@
<string name="screen_chat_backup_recovery_action_confirm">"Utiliser la clé de récupération"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Le stockage de vos clés est actuellement désynchronisé."</string>
<string name="screen_chat_backup_recovery_action_setup">"Configurer la sauvegarde"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Accédez à vos messages chiffrés si vous perdez tous vos appareils ou que vous êtes déconnectés de %1$s partout."</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Accédez à vos messages chiffrés si vous perdez tous vos appareils ou que vous êtes déconnecté de %1$s partout."</string>
<string name="screen_create_new_recovery_key_list_item_1">"Ouvrez %1$s sur un ordinateur"</string>
<string name="screen_create_new_recovery_key_list_item_2">"Connectez-vous à nouveau à votre compte"</string>
<string name="screen_create_new_recovery_key_list_item_3">"Lorsque vous devrez vérifier la session, choisissez %1$s"</string>
@ -31,7 +31,7 @@
<string name="screen_key_backup_disable_confirmation_title">"Êtes-vous certain de vouloir désactiver la sauvegarde ?"</string>
<string name="screen_key_backup_disable_description">"Désactiver la sauvegarde supprimera votre clé de récupération actuelle et désactivera dautres mesures de sécurité. Dans ce cas :"</string>
<string name="screen_key_backup_disable_description_point_1">"Pas daccès à lhistorique des discussions chiffrées sur vos nouveaux appareils"</string>
<string name="screen_key_backup_disable_description_point_2">"Perte de laccès à vos messages chiffrés si vous êtes déconnectés de %1$s partout"</string>
<string name="screen_key_backup_disable_description_point_2">"Perte de laccès à vos messages chiffrés si vous êtes déconnecté de %1$s partout"</string>
<string name="screen_key_backup_disable_title">"Êtes-vous certain de vouloir désactiver la sauvegarde ?"</string>
<string name="screen_recovery_key_change_description">"Obtenez une nouvelle clé de récupération dans le cas où vous avez oublié lancienne. Après le changement, lancienne clé ne sera plus utilisable."</string>
<string name="screen_recovery_key_change_generate_key">"Générer une nouvelle clé"</string>

View file

@ -20,7 +20,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
@ -37,7 +38,7 @@ class IncomingVerificationPresenter @AssistedInject constructor(
@Assisted private val navigator: IncomingVerificationNavigator,
private val sessionVerificationService: SessionVerificationService,
private val stateMachine: IncomingVerificationStateMachine,
private val dateFormatter: LastMessageTimestampFormatter,
private val dateFormatter: DateFormatter,
) : Presenter<IncomingVerificationState> {
@AssistedFactory
interface Factory {
@ -59,7 +60,10 @@ class IncomingVerificationPresenter @AssistedInject constructor(
}
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val formattedSignInTime = remember {
dateFormatter.format(sessionVerificationRequestDetails.firstSeenTimestamp)
dateFormatter.format(
timestamp = sessionVerificationRequestDetails.firstSeenTimestamp,
mode = DateFormatterMode.TimeOrDate,
)
}
val step by remember {
derivedStateOf {

View file

@ -16,7 +16,7 @@
<string name="screen_session_verification_compare_numbers_subtitle">"Varmista, että alla olevat numerot vastaavat toisessa istunnossa näkyviä numeroita."</string>
<string name="screen_session_verification_compare_numbers_title">"Vertaa numeroita"</string>
<string name="screen_session_verification_complete_subtitle">"Uusi kirjautumisesi on nyt vahvistettu. Sillä on pääsy salattuihin viesteihisi, ja muut käyttäjät näkevät sen luotettuna."</string>
<string name="screen_session_verification_enter_recovery_key">"Käytä palautusavainta"</string>
<string name="screen_session_verification_enter_recovery_key">"Syötä palautusavain"</string>
<string name="screen_session_verification_failed_subtitle">"Joko pyyntö aikakatkaistiin, pyyntö hylättiin tai vahvistus ei täsmännyt."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Vahvista, että se olet sinä, jotta näet aiemmat salatut viestisi."</string>
<string name="screen_session_verification_open_existing_session_title">"Avaa laite, jossa olet jo kirjautuneena"</string>

View file

@ -9,9 +9,8 @@ package io.element.android.features.verifysession.impl.incoming
import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.A_FORMATTED_DATE
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -56,7 +55,7 @@ class IncomingVerificationPresenterTest {
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
formattedSignInTime = "567 TimeOrDate false",
isWaiting = false,
)
)
@ -119,7 +118,7 @@ class IncomingVerificationPresenterTest {
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
formattedSignInTime = "567 TimeOrDate false",
isWaiting = false,
)
)
@ -178,7 +177,7 @@ class IncomingVerificationPresenterTest {
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
formattedSignInTime = "567 TimeOrDate false",
isWaiting = false,
)
)
@ -210,7 +209,7 @@ class IncomingVerificationPresenterTest {
IncomingVerificationState.Step.Initial(
deviceDisplayName = "a device name",
deviceId = A_DEVICE_ID,
formattedSignInTime = A_FORMATTED_DATE,
formattedSignInTime = "567 TimeOrDate false",
isWaiting = false,
)
)
@ -281,7 +280,7 @@ class IncomingVerificationPresenterTest {
sessionVerificationRequestDetails: SessionVerificationRequestDetails = aSessionVerificationRequestDetails,
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
service: SessionVerificationService = FakeSessionVerificationService(),
dateFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(A_FORMATTED_DATE),
dateFormatter: DateFormatter = FakeDateFormatter(),
) = IncomingVerificationPresenter(
sessionVerificationRequestDetails = sessionVerificationRequestDetails,
navigator = navigator,