Merge branch 'develop' into feature/fga/permalink_timeline

This commit is contained in:
Benoit Marty
2024-04-26 12:50:38 +02:00
1157 changed files with 4307 additions and 1899 deletions

View File

@@ -219,6 +219,7 @@ dependencies {
allServicesImpl()
allFeaturesImpl(rootDir, logger)
implementation(projects.features.call)
implementation(projects.features.migration.api)
implementation(projects.anvilannotations)
implementation(projects.appnav)
implementation(projects.appconfig)

View File

@@ -86,6 +86,7 @@ class MainActivity : NodeActivity() {
appBindings.preferencesStore().getThemeFlow().mapToTheme()
}
.collectAsState(initial = Theme.System)
val migrationState = appBindings.migrationEntryPoint().present()
ElementTheme(
darkTheme = theme.isDark()
) {
@@ -98,19 +99,12 @@ class MainActivity : NodeActivity() {
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(
it,
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node
mainNode.handleIntent(intent)
}
}
),
context = applicationContext
if (migrationState.migrationAction.isSuccess()) {
MainNodeHost()
} else {
appBindings.migrationEntryPoint().Render(
state = migrationState,
modifier = Modifier,
)
}
}
@@ -118,6 +112,25 @@ class MainActivity : NodeActivity() {
}
}
@Composable
private fun MainNodeHost() {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(
it,
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node
mainNode.handleIntent(intent)
}
}
),
context = applicationContext
)
}
}
/**
* Called when:
* - the launcher icon is clicked (if the app is already running);

View File

@@ -17,6 +17,7 @@
package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.rageshake.api.reporter.BugReporter
@@ -35,4 +36,6 @@ interface AppBindings {
fun lockScreenService(): LockScreenService
fun preferencesStore(): AppPreferencesStore
fun migrationEntryPoint(): MigrationEntryPoint
}

View File

@@ -17,10 +17,9 @@
package io.element.android.appnav.room
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
@@ -36,7 +35,11 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.appnav.room.joined.LoadingRoomState
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView
@@ -44,7 +47,6 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
@@ -69,6 +71,7 @@ class RoomFlowNode @AssistedInject constructor(
private val roomMembershipObserver: RoomMembershipObserver,
private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val networkMonitor: NetworkMonitor,
) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Loading,
@@ -125,7 +128,15 @@ class RoomFlowNode @AssistedInject constructor(
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
val info = roomInfo.getOrNull()
if (info?.currentUserMembership == CurrentUserMembership.JOINED) {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
if (info.isSpace) {
// It should not happen, but probably due to an issue in the sliding sync,
// we can have a space here in case the space has just been joined.
// So navigate to the JoinRoom target for now, which will
// handle the space not supported screen
backstack.newRoot(NavTarget.JoinRoom(roomId))
} else {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
} else {
backstack.newRoot(NavTarget.JoinRoom(roomId))
}
@@ -175,10 +186,14 @@ class RoomFlowNode @AssistedInject constructor(
}
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
val networkStatus by networkMonitor.connectivity.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
hasNetworkConnection = networkStatus == NetworkStatus.Online,
onBackClicked = { navigateUp() },
modifier = modifier,
)
}
@Composable

View File

@@ -68,7 +68,7 @@ fun RootView(
@PreviewsDayNight
@Composable
internal fun RootPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
RootView(
state = rootState,
onOpenBugReport = {},

1
changelog.d/2718.bugfix Normal file
View File

@@ -0,0 +1 @@
Fix session verification being asked again for already verified users.

1
changelog.d/2740.bugfix Normal file
View File

@@ -0,0 +1 @@
Instead of displaying 'create new recovery key' on the session verification screen when there is no other session active, display it always under the 'enter recovery key' screen.

1
changelog.d/2749.misc Normal file
View File

@@ -0,0 +1 @@
Migrate application data.

View File

@@ -43,8 +43,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight

View File

@@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
@@ -60,6 +61,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils)

View File

@@ -143,7 +143,7 @@ class FtueFlowNode @AssistedInject constructor(
}
}
private fun moveToNextStep() {
private fun moveToNextStep() = lifecycleScope.launch {
when (ftueState.getNextStep()) {
FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification)

View File

@@ -41,8 +41,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData

View File

@@ -58,9 +58,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize
data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
}
interface Callback : Plugin {
@@ -68,10 +65,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
}
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() {
lifecycleScope.launch {
// Move to the completed state view in the verification flow
@@ -89,10 +82,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
backstack.push(NavTarget.EnterRecoveryKey)
}
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() {
plugins<Callback>().forEach { it.onDone() }
}
@@ -105,12 +94,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
.callback(secureBackupEntryPointCallback)
.build()
}
is NavTarget.CreateNewRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
.callback(secureBackupEntryPointCallback)
.build()
}
}
}

View File

@@ -23,18 +23,26 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class)
class DefaultFtueService @Inject constructor(
@@ -44,6 +52,7 @@ class DefaultFtueService @Inject constructor(
private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService,
private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
@@ -55,7 +64,7 @@ class DefaultFtueService @Inject constructor(
}
init {
sessionVerificationService.needsVerificationFlow
sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() }
.launchIn(coroutineScope)
@@ -64,7 +73,7 @@ class DefaultFtueService @Inject constructor(
.launchIn(coroutineScope)
}
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (isSessionNotVerified()) {
FtueStep.SessionVerification
@@ -89,8 +98,8 @@ class DefaultFtueService @Inject constructor(
FtueStep.AnalyticsOptIn -> null
}
private fun isAnyStepIncomplete(): Boolean {
return listOf(
private suspend fun isAnyStepIncomplete(): Boolean {
return listOf<suspend () -> Boolean>(
{ isSessionNotVerified() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
@@ -98,16 +107,28 @@ class DefaultFtueService @Inject constructor(
).any { it() }
}
private fun isSessionNotVerified(): Boolean {
return sessionVerificationService.needsVerificationFlow.value
@OptIn(FlowPreview::class)
private suspend fun isSessionNotVerified(): Boolean {
// Wait for the first known (or ready) verification status
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
.filter { it != SessionVerifiedStatus.Unknown }
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
.timeout(5.seconds)
.catch {
Timber.e(it, "Failed to get session verification status, assume it's not verified")
emit(SessionVerifiedStatus.NotVerified)
}
.first()
val skipVerification = suspend { sessionPreferencesStore.isSessionVerificationSkipped().first() }
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !skipVerification()
}
private fun needsAnalyticsOptIn(): Boolean {
private suspend fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() }
return analyticsService.didAskUserConsent().first().not()
}
private fun shouldAskNotificationPermissions(): Boolean {
private suspend fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
@@ -118,14 +139,12 @@ class DefaultFtueService @Inject constructor(
}
}
private fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking {
lockScreenService.isSetupRequired().first()
}
private suspend fun shouldDisplayLockscreenSetup(): Boolean {
return lockScreenService.isSetupRequired().first()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() {
internal suspend fun updateState() {
state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete

View File

@@ -22,7 +22,7 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Няправільны QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перайсці ў налады камеры"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць Element выкарыстоўваць камеру вашай прылады."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Дазвольце доступ да камеры для сканавання QR-кода"</string>
<string name="screen_qr_code_login_scanning_state_title">"Сканаваць QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Пачаць спачатку"</string>

View File

@@ -22,7 +22,7 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Špatný QR kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Přejděte na nastavení fotoaparátu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci Element udělit povolení k použití kamery vašeho zařízení."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povolte přístup k fotoaparátu a naskenujte QR kód"</string>
<string name="screen_qr_code_login_scanning_state_title">"Naskenujte QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začít znovu"</string>

View File

@@ -9,6 +9,8 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Wenn das nicht funktioniert, melde dich manuell an"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Die Verbindung ist nicht sicher"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."</string>
<string name="screen_qr_code_login_device_code_title">"Nummer auf deinem Gerät eingeben"</string>
<string name="screen_qr_code_login_initial_state_item_1">"%1$s auf einem Desktop-Gerät öffnen"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klick auf deinen Avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string>
@@ -20,11 +22,14 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Falscher QR-Code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Gehe zu den Kameraeinstellungen"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst Element die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"</string>
<string name="screen_qr_code_login_scanning_state_title">"QR-Code scannen"</string>
<string name="screen_qr_code_login_start_over_button">"Neu beginnen"</string>
<string name="screen_qr_code_login_unknown_error_description">"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."</string>
<string name="screen_qr_code_login_verify_code_loading">"Warten auf dein anderes Gerät"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen."</string>
<string name="screen_qr_code_login_verify_code_title">"Dein Verifizierungscode"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit."</string>

View File

@@ -9,6 +9,8 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ha ez nem működik, jelentkezzen be kézileg"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string>
<string name="screen_qr_code_login_device_code_subtitle">"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet."</string>
<string name="screen_qr_code_login_device_code_title">"Adja meg a számot az eszközén"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Nyissa meg az %1$set egy asztali eszközön"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Kattintson a profilképére"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string>
@@ -20,11 +22,14 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Hibás QR-kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Ugrás a kamerabeállításokhoz"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az Element használhassa az eszköz kameráját."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Engedélyezze a kamera elérését a QR-kód beolvasásához"</string>
<string name="screen_qr_code_login_scanning_state_title">"Olvassa be a QR-kódot"</string>
<string name="screen_qr_code_login_start_over_button">"Újrakezdés"</string>
<string name="screen_qr_code_login_unknown_error_description">"Váratlan hiba történt. Próbálja meg újra."</string>
<string name="screen_qr_code_login_verify_code_loading">"Várakozás a másik eszközre"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez."</string>
<string name="screen_qr_code_login_verify_code_title">"Az Ön ellenőrzőkódja"</string>
<string name="screen_welcome_bullet_1">"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."</string>
<string name="screen_welcome_bullet_2">"A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben."</string>
<string name="screen_welcome_bullet_3">"Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon."</string>

View File

@@ -3,6 +3,14 @@
<string name="screen_notification_optin_subtitle">"Anda dapat mengubah pengaturan Anda nanti."</string>
<string name="screen_notification_optin_title">"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Membuat koneksi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Jika tidak berhasil, masuk secara manual"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Koneksi tidak aman"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini."</string>
<string name="screen_qr_code_login_device_code_title">"Masukkan nomor di perangkat Anda"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Buka %1$s di perangkat desktop"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik pada avatar Anda"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string>
@@ -13,7 +21,15 @@
<string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Kode QR salah"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Pergi ke pengaturan kamera"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Izinkan akses kamera untuk memindai kode QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Pindai kode QR"</string>
<string name="screen_qr_code_login_start_over_button">"Mulai dari awal"</string>
<string name="screen_qr_code_login_unknown_error_description">"Terjadi kesalahan tak terduga. Silakan coba lagi."</string>
<string name="screen_qr_code_login_verify_code_loading">"Menunggu perangkat Anda yang lain"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk."</string>
<string name="screen_qr_code_login_verify_code_title">"Kode verifikasi Anda"</string>
<string name="screen_welcome_bullet_1">"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."</string>
<string name="screen_welcome_bullet_2">"Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini."</string>
<string name="screen_welcome_bullet_3">"Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan."</string>

View File

@@ -9,6 +9,8 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Вам будет предложено ввести две цифры, показанные ниже."</string>
<string name="screen_qr_code_login_device_code_title">"Введите номер на своем устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на настольном устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
@@ -20,11 +22,14 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неверный QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перейдите в настройки камеры"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить Element использовать камеру вашего устройства."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Разрешите доступ к камере для сканирования QR-кода"</string>
<string name="screen_qr_code_login_scanning_state_title">"Сканировать QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Начать заново"</string>
<string name="screen_qr_code_login_unknown_error_description">"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_verify_code_loading">"В ожидании другого устройства"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Поставщик учетной записи может запросить следующий код для подтверждения входа."</string>
<string name="screen_qr_code_login_verify_code_title">"Ваш код подтверждения"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View File

@@ -22,7 +22,7 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Nesprávny QR kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Prejsť na nastavenia fotoaparátu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii Element používať fotoaparát vášho zariadenia."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povoľte prístup k fotoaparátu na naskenovanie QR kódu"</string>
<string name="screen_qr_code_login_scanning_state_title">"Naskenovať QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začať odznova"</string>

View File

@@ -9,8 +9,8 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"If that doesnt work, sign in manually"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connection not secure"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Youll be asked to enter the two digits shown below."</string>
<string name="screen_qr_code_login_device_code_title">"Enter number on your device"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Youll be asked to enter the two digits shown on this device."</string>
<string name="screen_qr_code_login_device_code_title">"Enter the number below on your other device"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open %1$s on a desktop device"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
@@ -22,7 +22,7 @@
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Wrong QR code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Go to camera settings"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for Element to use your devices camera in order to continue."</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for %1$s to use your devices camera in order to continue."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Allow camera access to scan the QR code"</string>
<string name="screen_qr_code_login_scanning_state_title">"Scan the QR code"</string>
<string name="screen_qr_code_login_start_over_button">"Start over"</string>

View File

@@ -24,6 +24,7 @@ import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
@@ -90,7 +91,6 @@ class DefaultFtueServiceTests {
fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
}
val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@@ -108,7 +108,7 @@ class DefaultFtueServiceTests {
// Session verification
steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenNeedsVerification(false)
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
@@ -200,6 +200,7 @@ class DefaultFtueServiceTests {
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService(
@@ -209,5 +210,6 @@ class DefaultFtueServiceTests {
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
sessionPreferencesStore = sessionPreferencesStore,
)
}

View File

@@ -102,12 +102,14 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncAction<RoomId>>) = launch {
acceptedAction.runUpdatingState {
client.joinRoom(roomId).onSuccess {
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
client.getRoom(roomId)?.use { room ->
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
client.joinRoom(roomId)
.onSuccess {
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
client.getRoom(roomId)?.use { room ->
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
}
}
}
.map { roomId }
}
}

View File

@@ -164,7 +164,7 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - accepting invite error flow`() = runTest {
val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
Result.failure<RoomId>(RuntimeException("Failed to join room $roomId"))
Result.failure<Unit>(RuntimeException("Failed to join room $roomId"))
}
val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomFailure
@@ -197,8 +197,8 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - accepting invite success flow`() = runTest {
val joinRoomSuccess = lambdaRecorder { roomId: RoomId ->
Result.success(roomId)
val joinRoomSuccess = lambdaRecorder { _: RoomId ->
Result.success(Unit)
}
val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomSuccess

View File

@@ -23,6 +23,11 @@ plugins {
android {
namespace = "io.element.android.features.joinroom.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
anvil {
@@ -46,10 +51,13 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}

View File

@@ -19,6 +19,8 @@ package io.element.android.features.joinroom.impl
sealed interface JoinRoomEvents {
data object RetryFetchingContent : JoinRoomEvents
data object JoinRoom : JoinRoomEvents
data object KnockRoom : JoinRoomEvents
data object ClearError : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents
}

View File

@@ -49,6 +49,7 @@ class JoinRoomNode @AssistedInject constructor(
JoinRoomView(
state = state,
onBackPressed = ::navigateUp,
onKnockSuccess = ::navigateUp,
modifier = modifier
)
acceptDeclineInviteView.Render(

View File

@@ -18,26 +18,37 @@ package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Optional
class JoinRoomPresenter @AssistedInject constructor(
@@ -45,7 +56,9 @@ class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomIdOrAlias: RoomIdOrAlias,
@Assisted private val roomDescription: Optional<RoomDescription>,
private val matrixClient: MatrixClient,
private val knockRoom: KnockRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta,
) : Presenter<JoinRoomState> {
interface Factory {
fun create(
@@ -57,8 +70,10 @@ class JoinRoomPresenter @AssistedInject constructor(
@Composable
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val contentState by produceState<ContentState>(
initialValue = ContentState.Loading(roomIdOrAlias),
key1 = roomInfo,
@@ -75,7 +90,9 @@ class JoinRoomPresenter @AssistedInject constructor(
value = ContentState.Loading(roomIdOrAlias)
val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias())
value = result.fold(
onSuccess = { it.toContentState() },
onSuccess = { roomPreview ->
roomPreview.toContentState()
},
onFailure = { throwable ->
if (throwable.message?.contains("403") == true) {
ContentState.UnknownRoom(roomIdOrAlias)
@@ -98,6 +115,9 @@ class JoinRoomPresenter @AssistedInject constructor(
AcceptDeclineInviteEvents.AcceptInvite(inviteData)
)
}
JoinRoomEvents.KnockRoom -> {
coroutineScope.knockRoom(roomId, knockAction)
}
JoinRoomEvents.DeclineInvite -> {
val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink(
@@ -107,15 +127,26 @@ class JoinRoomPresenter @AssistedInject constructor(
JoinRoomEvents.RetryFetchingContent -> {
retryCount++
}
JoinRoomEvents.ClearError -> {
knockAction.value = AsyncAction.Uninitialized
}
}
}
return JoinRoomState(
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
knockAction = knockAction.value,
applicationName = buildMeta.applicationName,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.knockRoom(roomId: RoomId, knockAction: MutableState<AsyncAction<Unit>>) = launch {
knockAction.runUpdatingState {
knockRoom(roomId)
}
}
}
private fun RoomPreview.toContentState(): ContentState {
@@ -126,9 +157,11 @@ private fun RoomPreview.toContentState(): ContentState {
alias = canonicalAlias,
numberOfMembers = numberOfJoinedMembers,
isDirect = false,
roomType = roomType,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when {
isInvited -> JoinAuthorisationStatus.IsInvited
// Note when isInvited, roomInfo will be used, so if this happen, it will be temporary.
isInvited -> JoinAuthorisationStatus.IsInvited(null)
canKnock -> JoinAuthorisationStatus.CanKnock
isPublic -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown
@@ -145,6 +178,7 @@ internal fun RoomDescription.toContentState(): ContentState {
alias = alias,
numberOfMembers = numberOfMembers,
isDirect = false,
roomType = RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (joinRule) {
RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock
@@ -163,9 +197,12 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
alias = canonicalAlias,
numberOfMembers = activeMembersCount,
isDirect = isDirect,
roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when {
currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited
currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
inviteSender = inviter?.toInviteSender()
)
isPublic -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown
}
@@ -177,7 +214,8 @@ internal fun ContentState.toInviteData(): InviteData? {
return when (this) {
is ContentState.Loaded -> InviteData(
roomId = roomId,
roomName = computedTitle,
// Note: name should not be null at this point, but use Id just in case...
roomName = name ?: roomId.value,
isDirect = isDirect
)
else -> null

View File

@@ -18,16 +18,21 @@ package io.element.android.features.joinroom.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
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.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
@Immutable
data class JoinRoomState(
val contentState: ContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val knockAction: AsyncAction<Unit>,
val applicationName: String,
val eventSink: (JoinRoomEvents) -> Unit
) {
val joinAuthorisationStatus = when (contentState) {
@@ -47,17 +52,10 @@ sealed interface ContentState {
val alias: RoomAlias?,
val numberOfMembers: Long?,
val isDirect: Boolean,
val roomType: RoomType,
val roomAvatarUrl: String?,
val joinAuthorisationStatus: JoinAuthorisationStatus,
) : ContentState {
val computedTitle = name ?: roomId.value
val computedSubtitle = when {
alias != null -> alias.value
name == null -> ""
else -> roomId.value
}
val showMemberCount = numberOfMembers != null
fun avatarData(size: AvatarSize): AvatarData {
@@ -71,9 +69,9 @@ sealed interface ContentState {
}
}
enum class JoinAuthorisationStatus {
IsInvited,
CanKnock,
CanJoin,
Unknown,
sealed interface JoinAuthorisationStatus {
data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
data object CanKnock : JoinAuthorisationStatus
data object CanJoin : JoinAuthorisationStatus
data object Unknown : JoinAuthorisationStatus
}

View File

@@ -19,10 +19,16 @@ package io.element.android.features.joinroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
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.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
override val values: Sequence<JoinRoomState>
@@ -33,6 +39,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
aJoinRoomState(
contentState = anUnknownContentState()
),
aJoinRoomState(
contentState = aLoadedContentState(
name = null,
alias = null,
topic = null,
)
),
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
),
@@ -48,7 +61,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
)
),
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited)
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
),
aJoinRoomState(
contentState = aLoadedContentState(
numberOfMembers = 123,
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(anInviteSender()),
)
),
aJoinRoomState(
contentState = aFailureContentState()
@@ -56,6 +75,15 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
aJoinRoomState(
contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias())
),
aJoinRoomState(
contentState = aLoadedContentState(
roomId = RoomId("!aSpaceId:domain"),
name = "A space",
alias = null,
topic = "This is the topic of a space",
roomType = RoomType.Space,
)
),
)
}
@@ -74,11 +102,12 @@ fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(room
fun aLoadedContentState(
roomId: RoomId = A_ROOM_ID,
name: String = "Element X android",
name: String? = "Element X android",
alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
topic: String? = "Element X is a secure, private and decentralized messenger.",
numberOfMembers: Long? = null,
isDirect: Boolean = false,
roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
) = ContentState.Loaded(
@@ -88,6 +117,7 @@ fun aLoadedContentState(
topic = topic,
numberOfMembers = numberOfMembers,
isDirect = isDirect,
roomType = roomType,
roomAvatarUrl = roomAvatarUrl,
joinAuthorisationStatus = joinAuthorisationStatus
)
@@ -95,12 +125,25 @@ fun aLoadedContentState(
fun aJoinRoomState(
contentState: ContentState = aLoadedContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState(
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
knockAction = knockAction,
applicationName = "AppName",
eventSink = eventSink
)
internal fun anInviteSender(
userId: UserId = UserId("@bob:domain"),
displayName: String = "Bob",
avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
) = InviteSender(
userId = userId,
displayName = displayName,
avatarData = avatarData,
)
private val A_ROOM_ID = RoomId("!exa:matrix.org")
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")

View File

@@ -17,16 +17,25 @@
package io.element.android.features.joinroom.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
@@ -35,9 +44,12 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolec
import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.LightGradientBackground
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -46,40 +58,61 @@ import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun JoinRoomView(
state: JoinRoomState,
onBackPressed: () -> Unit,
onKnockSuccess: () -> Unit,
modifier: Modifier = Modifier,
) {
HeaderFooterPage(
modifier = modifier,
paddingValues = PaddingValues(16.dp),
topBar = {
JoinRoomTopBar(onBackClicked = onBackPressed)
},
content = {
JoinRoomContent(contentState = state.contentState)
},
footer = {
JoinRoomFooter(
state = state,
onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite)
},
onDeclineInvite = {
state.eventSink(JoinRoomEvents.DeclineInvite)
},
onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom)
},
onRetry = {
state.eventSink(JoinRoomEvents.RetryFetchingContent)
}
)
}
Box(
modifier = modifier.fillMaxSize(),
) {
LightGradientBackground()
HeaderFooterPage(
containerColor = Color.Transparent,
paddingValues = PaddingValues(16.dp),
topBar = {
JoinRoomTopBar(onBackClicked = onBackPressed)
},
content = {
JoinRoomContent(
contentState = state.contentState,
applicationName = state.applicationName,
)
},
footer = {
JoinRoomFooter(
state = state,
onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite)
},
onDeclineInvite = {
state.eventSink(JoinRoomEvents.DeclineInvite)
},
onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom)
},
onKnockRoom = {
state.eventSink(JoinRoomEvents.KnockRoom)
},
onRetry = {
state.eventSink(JoinRoomEvents.RetryFetchingContent)
},
onGoBack = onBackPressed,
)
}
)
}
AsyncActionView(
async = state.knockAction,
onSuccess = { onKnockSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) },
)
}
@@ -89,7 +122,9 @@ private fun JoinRoomFooter(
onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit,
onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit,
onRetry: () -> Unit,
onGoBack: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.contentState is ContentState.Failure) {
@@ -97,41 +132,51 @@ private fun JoinRoomFooter(
text = stringResource(CommonStrings.action_retry),
onClick = onRetry,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium,
size = ButtonSize.Large,
)
} else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) {
Button(
text = stringResource(CommonStrings.action_go_back),
onClick = onGoBack,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Large,
)
} else {
val joinAuthorisationStatus = state.joinAuthorisationStatus
when (joinAuthorisationStatus) {
JoinAuthorisationStatus.IsInvited -> {
is JoinAuthorisationStatus.IsInvited -> {
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineInvite,
modifier = Modifier.weight(1f),
size = ButtonSize.Medium,
size = ButtonSize.Large,
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite,
modifier = Modifier.weight(1f),
size = ButtonSize.Medium,
size = ButtonSize.Large,
)
}
}
JoinAuthorisationStatus.CanJoin -> {
Button(
text = stringResource(R.string.screen_join_room_join_action),
SuperButton(
onClick = onJoinRoom,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium,
)
buttonSize = ButtonSize.Large,
) {
Text(
text = stringResource(R.string.screen_join_room_join_action),
)
}
}
JoinAuthorisationStatus.CanKnock -> {
Button(
text = stringResource(R.string.screen_join_room_knock_action),
onClick = onJoinRoom,
onClick = onKnockRoom,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium,
size = ButtonSize.Large,
)
}
JoinAuthorisationStatus.Unknown -> Unit
@@ -142,6 +187,7 @@ private fun JoinRoomFooter(
@Composable
private fun JoinRoomContent(
contentState: ContentState,
applicationName: String,
modifier: Modifier = Modifier,
) {
when (contentState) {
@@ -152,13 +198,48 @@ private fun JoinRoomContent(
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
},
title = {
RoomPreviewTitleAtom(contentState.computedTitle)
if (contentState.name != null) {
RoomPreviewTitleAtom(
title = contentState.name,
)
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic
)
}
},
subtitle = {
RoomPreviewSubtitleAtom(contentState.computedSubtitle)
if (contentState.alias != null) {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
},
description = {
RoomPreviewDescriptionAtom(contentState.topic ?: "")
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
if (inviteSender != null) {
InviteSenderView(inviteSender = inviteSender)
}
RoomPreviewDescriptionAtom(contentState.topic ?: "")
if (contentState.roomType == RoomType.Space) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_title),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
},
memberCount = {
if (contentState.showMemberCount) {
@@ -213,7 +294,7 @@ private fun JoinRoomContent(
},
subtitle = {
Text(
text = "Failed to get information about the room",
text = stringResource(id = CommonStrings.error_unknown),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error,
)
@@ -241,6 +322,7 @@ private fun JoinRoomTopBar(
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
JoinRoomView(
state = state,
onBackPressed = { }
onBackPressed = { },
onKnockSuccess = { },
)
}

View File

@@ -23,6 +23,7 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -35,7 +36,9 @@ object JoinRoomModule {
@Provides
fun providesJoinRoomPresenterFactory(
client: MatrixClient,
knockRoom: KnockRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
override fun create(
@@ -48,7 +51,9 @@ object JoinRoomModule {
roomIdOrAlias = roomIdOrAlias,
roomDescription = roomDescription,
matrixClient = client,
knockRoom = knockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,
)
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.joinroom.impl.di
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
interface KnockRoom {
suspend operator fun invoke(roomId: RoomId): Result<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultKnockRoom @Inject constructor(private val client: MatrixClient) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = client.knockRoom(roomId)
}

View File

@@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Join room"</string>
<string name="screen_join_room_knock_action">"Knock to join"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string>
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string>
<string name="screen_join_room_subtitle_no_preview">"You must be a member of this room to view the message history."</string>
<string name="screen_join_room_title_knock">"Want to join this room?"</string>

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.joinroom.impl
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.libraries.matrix.api.core.RoomId
class FakeKnockRoom(
var lambda: (RoomId) -> Result<Unit> = { Result.success(Unit) }
) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = lambda(roomId)
}

View File

@@ -20,19 +20,27 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -56,6 +64,7 @@ class JoinRoomPresenterTest {
assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.applicationName).isEqualTo("AppName")
cancelAndIgnoreRemainingEvents()
}
}
@@ -101,7 +110,31 @@ class JoinRoomPresenterTest {
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited)
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
}
}
}
@Test
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
val expectedInviteSender = inviter.toInviteSender()
val roomInfo = aRoomInfo(
currentUserMembership = CurrentUserMembership.INVITED,
inviter = inviter,
)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(expectedInviteSender))
}
}
}
@@ -242,6 +275,38 @@ class JoinRoomPresenterTest {
}
}
@Test
fun `present - emit knock room event`() = runTest {
val knockRoomSuccess = lambdaRecorder { _: RoomId ->
Result.success(Unit)
}
val knockRoomFailure = lambdaRecorder { roomId: RoomId ->
Result.failure<Unit>(RuntimeException("Failed to knock room $roomId"))
}
val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess)
val presenter = createJoinRoomPresenter(knockRoom = fakeKnockRoom)
presenter.test {
skipItems(1)
awaitItem().also { state ->
state.eventSink(JoinRoomEvents.KnockRoom)
}
awaitItem().also { state ->
assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit))
fakeKnockRoom.lambda = knockRoomFailure
state.eventSink(JoinRoomEvents.KnockRoom)
}
awaitItem().also { state ->
assertThat(state.knockAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
assert(knockRoomSuccess)
.isCalledOnce()
.with(value(A_ROOM_ID))
assert(knockRoomFailure)
.isCalledOnce()
.with(value(A_ROOM_ID))
}
@Test
fun `present - when room is not known RoomPreview is loaded`() = runTest {
val client = FakeMatrixClient(
@@ -254,7 +319,7 @@ class JoinRoomPresenterTest {
topic = "Room topic",
avatarUrl = "avatarUrl",
numberOfJoinedMembers = 2,
roomType = null,
roomType = RoomType.Room,
isHistoryWorldReadable = false,
isJoined = false,
isInvited = false,
@@ -278,6 +343,7 @@ class JoinRoomPresenterTest {
alias = RoomAlias("#alias:matrix.org"),
numberOfMembers = 2,
isDirect = false,
roomType = RoomType.Room,
roomAvatarUrl = "avatarUrl",
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
)
@@ -350,6 +416,8 @@ class JoinRoomPresenterTest {
roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(),
matrixClient: MatrixClient = FakeMatrixClient(),
knockRoom: KnockRoom = FakeKnockRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
): JoinRoomPresenter {
return JoinRoomPresenter(
@@ -357,6 +425,8 @@ class JoinRoomPresenterTest {
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
matrixClient = matrixClient,
knockRoom = knockRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
)
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.joinroom.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class JoinRoomViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke the expected callback`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce {
rule.setJoinRoomView(
aJoinRoomState(
eventSink = eventsRecorder,
),
onBackPressed = it
)
rule.pressBack()
}
}
@Test
fun `clicking on Join room on CanJoin room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_join_room_join_action)
eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom)
}
@Test
fun `clicking on Knock room on CanKnock room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_join_room_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom)
}
@Test
fun `clicking on closing Knock error emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearError)
}
@Test
fun `clicking on Accept invitation IsInvited room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite)
}
@Test
fun `clicking on Decline invitation on IsInvited room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite)
}
@Test
fun `clicking on Retry when an error occurs emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aFailureContentState(),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent)
}
@Test
fun `clicking on Go back when a space is displayed invokes the expected callback`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce {
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(roomType = RoomType.Space),
eventSink = eventsRecorder,
),
onBackPressed = it
)
rule.clickOn(CommonStrings.action_go_back)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomView(
state: JoinRoomState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onKnockSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
JoinRoomView(
state = state,
onBackPressed = onBackPressed,
onKnockSuccess = onKnockSuccess,
)
}
}

View File

@@ -358,7 +358,7 @@ private fun PinUnlockFooter(
@Composable
@PreviewsDayNight
internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,
@@ -369,7 +369,7 @@ internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider:
@Composable
@PreviewsDayNight
internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview {
PinUnlockView(
state = state,

View File

@@ -6,6 +6,7 @@
<string name="screen_app_lock_settings_remove_pin">"Ta bort PIN-kod"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Är du säker på att du vill ta bort PIN-koden?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Ta bort PIN-koden?"</string>
<string name="screen_app_lock_signout_alert_message">"Du måste logga in igen och skapa en ny PIN-kod för att fortsätta"</string>
<string name="screen_app_lock_signout_alert_title">"Du blir utloggad"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Du har %1$d försök att låsa upp"</item>

View File

@@ -48,8 +48,11 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy(
reactionsState = reactionsState
event = aTimelineItemEvent(
content = aTimelineItemImageContent(),
displayNameAmbiguous = true,
).copy(
reactionsState = reactionsState,
),
displayEmojiReactions = true,
actions = aTimelineItemActionList(),
@@ -142,6 +145,7 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
TimelineItemAction.ViewSource,
)
}
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf(
TimelineItemAction.EndPoll,

View File

@@ -55,6 +55,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.sender.SenderName
import io.element.android.features.messages.impl.sender.SenderNameMode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
@@ -268,15 +270,11 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
icon()
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row {
if (event.senderDisplayName != null) {
Text(
text = event.senderDisplayName,
style = ElementTheme.typography.fontBodySmMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
SenderName(
senderId = event.senderId,
senderProfile = event.senderProfile,
senderNameMode = SenderNameMode.ActionList,
)
content()
}
Spacer(modifier = Modifier.width(16.dp))

View File

@@ -141,7 +141,7 @@ private fun RoomMemberSuggestionItemView(
@PreviewsDayNight
@Composable
internal fun MentionSuggestionsPickerView_Preview() {
internal fun MentionSuggestionsPickerViewPreview() {
ElementPreview {
val roomMember = RoomMember(
userId = UserId("@alice:server.org"),

View File

@@ -0,0 +1,134 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.sender
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
@Composable
fun SenderName(
senderId: UserId,
senderProfile: ProfileTimelineDetails,
senderNameMode: SenderNameMode,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (senderProfile) {
is ProfileTimelineDetails.Error,
ProfileTimelineDetails.Pending,
ProfileTimelineDetails.Unavailable -> {
MainText(text = senderId.value, mode = senderNameMode)
}
is ProfileTimelineDetails.Ready -> {
val displayName = senderProfile.displayName
if (displayName.isNullOrEmpty()) {
MainText(text = senderId.value, mode = senderNameMode)
} else {
MainText(text = displayName, mode = senderNameMode)
if (senderProfile.displayNameAmbiguous) {
SecondaryText(text = senderId.value, mode = senderNameMode)
}
}
}
}
}
}
@Composable
private fun RowScope.MainText(
text: String,
mode: SenderNameMode,
) {
val style = when (mode) {
is SenderNameMode.Timeline -> ElementTheme.typography.fontBodyMdMedium
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.typography.fontBodySmMedium
}
val modifier = when (mode) {
is SenderNameMode.Timeline -> Modifier.alignByBaseline()
SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier
}
val color = when (mode) {
is SenderNameMode.Timeline -> mode.mainColor
SenderNameMode.ActionList,
SenderNameMode.Reply -> MaterialTheme.colorScheme.primary
}
Text(
modifier = modifier.clipToBounds(),
text = text,
style = style,
color = color,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun RowScope.SecondaryText(
text: String,
mode: SenderNameMode,
) {
val style = when (mode) {
is SenderNameMode.Timeline -> ElementTheme.typography.fontBodySmRegular
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.typography.fontBodyXsRegular
}
val modifier = when (mode) {
is SenderNameMode.Timeline -> Modifier.alignByBaseline()
SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier
}
Text(
modifier = modifier.clipToBounds(),
text = text,
style = style,
color = MaterialTheme.colorScheme.secondary,
overflow = TextOverflow.Ellipsis,
)
}
@PreviewsDayNight
@Composable
internal fun SenderNamePreview(
@PreviewParameter(SenderNameDataProvider::class) senderNameData: SenderNameData,
) = ElementPreview {
SenderName(
senderId = senderNameData.userId,
senderProfile = senderNameData.profileTimelineDetails,
senderNameMode = senderNameData.senderNameMode,
)
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.sender
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
data class SenderNameData(
val userId: UserId,
val profileTimelineDetails: ProfileTimelineDetails,
val senderNameMode: SenderNameMode,
)
open class SenderNameDataProvider : PreviewParameterProvider<SenderNameData> {
override val values: Sequence<SenderNameData>
get() = sequenceOf(
SenderNameMode.Timeline(mainColor = Color.Red),
SenderNameMode.Reply,
SenderNameMode.ActionList,
)
.flatMap { senderNameMode ->
sequenceOf(
aSenderNameData(
senderNameMode = senderNameMode,
),
aSenderNameData(
senderNameMode = senderNameMode,
displayNameAmbiguous = true,
),
SenderNameData(
senderNameMode = senderNameMode,
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileTimelineDetails = ProfileTimelineDetails.Unavailable,
),
)
}
}
private fun aSenderNameData(
senderNameMode: SenderNameMode,
displayNameAmbiguous: Boolean = false,
) = SenderNameData(
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileTimelineDetails = aProfileTimelineDetailsReady(
displayName = "Alice ${senderNameMode.javaClass.simpleName}",
displayNameAmbiguous = displayNameAmbiguous,
),
senderNameMode = senderNameMode,
)

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.sender
import androidx.compose.ui.graphics.Color
sealed interface SenderNameMode {
data class Timeline(val mainColor: Color) : SenderNameMode
data object Reply : SenderNameMode
data object ActionList : SenderNameMode
}

View File

@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.NewEventState
@@ -130,6 +131,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false,
isEditable: Boolean = false,
senderDisplayName: String = "Sender",
displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
@@ -151,7 +153,10 @@ internal fun aTimelineItemEvent(
sentTime = "12:34",
isMine = isMine,
isEditable = isEditable,
senderDisplayName = senderDisplayName,
senderProfile = aProfileTimelineDetailsReady(
displayName = senderDisplayName,
displayNameAmbiguous = displayNameAmbiguous,
),
groupPosition = groupPosition,
localSendState = sendState,
inReplyTo = inReplyTo,

View File

@@ -191,7 +191,7 @@ internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionP
@PreviewsDayNight
@Composable
internal fun MessagesAddReactionButtonPreview() = ElementPreview {
internal fun MessagesReactionButtonAddPreview() = ElementPreview {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add),
onClick = {},
@@ -201,7 +201,7 @@ internal fun MessagesAddReactionButtonPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
internal fun MessagesReactionButtonExtraPreview() = ElementPreview {
Row {
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text("12 more"),

View File

@@ -69,6 +69,8 @@ import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.sender.SenderName
import io.element.android.features.messages.impl.sender.SenderNameMode
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
@@ -106,6 +108,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@@ -291,7 +295,8 @@ private fun TimelineItemEventRowContent(
val avatarStrokeSize = 3.dp
if (event.showSenderInformation && !timelineRoomInfo.isDm) {
MessageSenderInformation(
event.safeSenderName,
event.senderId,
event.senderProfile,
event.senderAvatar,
avatarStrokeSize,
Modifier
@@ -371,7 +376,8 @@ private fun TimelineItemEventRowContent(
@Composable
private fun MessageSenderInformation(
sender: String,
senderId: UserId,
senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData,
avatarStrokeSize: Dp,
modifier: Modifier = Modifier
@@ -398,13 +404,10 @@ private fun MessageSenderInformation(
Row {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
Text(
modifier = Modifier.clipToBounds(),
text = sender,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = avatarColors.foreground,
style = ElementTheme.typography.fontBodyMdMedium,
SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
)
}
}
@@ -561,10 +564,10 @@ private fun MessageEventBubbleContent(
}
}
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent(
senderName = senderName,
senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(),
modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
@@ -609,7 +612,8 @@ private fun MessageEventBubbleContent(
@Composable
private fun ReplyToContent(
senderName: String,
senderId: UserId,
senderProfile: ProfileTimelineDetails,
metadata: InReplyToMetadata?,
modifier: Modifier = Modifier,
) {
@@ -633,18 +637,15 @@ private fun ReplyToContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
Column(verticalArrangement = Arrangement.SpaceBetween) {
Text(
SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Reply,
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
},
text = senderName,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ReplyToContentText(metadata)
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowDisambiguatedPreview(
@PreviewParameter(InReplyToDetailsDisambiguatedProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(
inReplyToDetails = inReplyToDetails,
displayNameAmbiguous = true,
)
}
class InReplyToDetailsDisambiguatedProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
aMessageContent(
body = "Message which are being replied.",
type = TextMessageType("Message which are being replied.", null)
),
).map {
aInReplyToDetails(
displayNameAmbiguous = true,
eventContent = it,
)
}
}

View File

@@ -43,7 +43,6 @@ internal fun TimelineItemEventRowTimestampPreview(
body = str,
),
reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = "A sender",
),
)
}

View File

@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageConten
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
@@ -58,7 +59,10 @@ internal fun TimelineItemEventRowWithReplyPreview(
}
@Composable
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
internal fun TimelineItemEventRowWithReplyContentToPreview(
inReplyToDetails: InReplyToDetails,
displayNameAmbiguous: Boolean = false,
) {
Column {
sequenceOf(false, true).forEach {
ATimelineItemEventRow(
@@ -69,6 +73,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
body = "A reply."
),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
groupPosition = TimelineItemGroupPosition.First,
),
)
@@ -80,6 +85,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
aspectRatio = 2.5f
),
inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last,
),
@@ -150,7 +156,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
)
}
private fun aMessageContent(
protected fun aMessageContent(
body: String,
type: MessageType,
) = MessageContent(
@@ -163,12 +169,24 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
protected fun aInReplyToDetails(
eventContent: EventContent,
displayNameAmbiguous: Boolean = false,
) = InReplyToDetails(
eventId = EventId("\$event"),
eventContent = eventContent,
senderId = UserId("@Sender:domain"),
senderDisplayName = "Sender",
senderAvatarUrl = null,
senderProfile = aProfileTimelineDetailsReady(
displayNameAmbiguous = displayNameAmbiguous,
),
textContent = (eventContent as? MessageContent)?.body.orEmpty(),
)
}
internal fun aProfileTimelineDetailsReady(
displayName: String? = "Sender",
displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null,
) = ProfileTimelineDetails.Ready(
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
)

View File

@@ -207,7 +207,7 @@ private fun computeReceiptDescription(receipts: ImmutableList<ReadReceiptData>):
@PreviewsDayNight
@Composable
internal fun TimelineItemReactionsViewPreview(
internal fun TimelineItemReadReceiptViewPreview(
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
) = ElementPreview {
TimelineItemReadReceiptView(

View File

@@ -52,8 +52,12 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
val senderDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
val senderDisambiguatedDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
messageFactory.create(
content = itemContent,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
eventId = eventTimelineItem.eventId,
)
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent)

View File

@@ -70,17 +70,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser,
) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
suspend fun create(
content: MessageContent,
senderDisambiguatedDisplayName: String,
eventId: EventId?,
): TimelineItemEventContent {
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent(
body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisplayName",
prefix = "* $senderDisambiguatedDisplayName",
),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(),
isEdited = content.isEdited,
)
}

View File

@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat
@@ -55,15 +55,14 @@ class TimelineItemEventFactory @Inject constructor(
val currentSender = currentTimelineItem.event.sender
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val (senderDisplayName, senderAvatarUrl) = currentTimelineItem.getSenderInfo()
val senderProfile = currentTimelineItem.event.senderProfile
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
val senderAvatarData = AvatarData(
id = currentSender.value,
name = senderDisplayName ?: currentSender.value,
url = senderAvatarUrl,
name = senderProfile.getDisambiguatedDisplayName(currentSender),
url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender
)
currentTimelineItem.event
@@ -72,7 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
eventId = currentTimelineItem.eventId,
transactionId = currentTimelineItem.transactionId,
senderId = currentSender,
senderDisplayName = senderDisplayName,
senderProfile = senderProfile,
senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn,
@@ -99,26 +98,6 @@ class TimelineItemEventFactory @Inject constructor(
)
}
private fun MatrixTimelineItem.Event.getSenderInfo(): Pair<String?, String?> {
val senderDisplayName: String?
val senderAvatarUrl: String?
when (val senderProfile = event.senderProfile) {
ProfileTimelineDetails.Unavailable,
ProfileTimelineDetails.Pending,
is ProfileTimelineDetails.Error -> {
senderDisplayName = null
senderAvatarUrl = null
}
is ProfileTimelineDetails.Ready -> {
senderDisplayName = senderProfile.getDisambiguatedDisplayName(event.sender)
senderAvatarUrl = senderProfile.avatarUrl
}
}
return senderDisplayName to senderAvatarUrl
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = event.reactions.map { reaction ->

View File

@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
@@ -29,8 +30,7 @@ import io.element.android.libraries.matrix.ui.messages.toPlainText
data class InReplyToDetails(
val eventId: EventId,
val senderId: UserId,
val senderDisplayName: String?,
val senderAvatarUrl: String?,
val senderProfile: ProfileTimelineDetails,
val eventContent: EventContent?,
val textContent: String?,
)
@@ -41,8 +41,7 @@ fun InReplyTo.map(
is InReplyTo.Ready -> InReplyToDetails(
eventId = eventId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderAvatarUrl = senderAvatarUrl,
senderProfile = senderProfile,
eventContent = content,
textContent = when (content) {
is MessageContent -> {

View File

@@ -27,7 +27,9 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.ImmutableList
@Immutable
@@ -65,7 +67,7 @@ sealed interface TimelineItem {
val eventId: EventId? = null,
val transactionId: TransactionId? = null,
val senderId: UserId,
val senderDisplayName: String?,
val senderProfile: ProfileTimelineDetails,
val senderAvatar: AvatarData,
val content: TimelineItemEventContent,
val sentTime: String = "",
@@ -82,7 +84,7 @@ sealed interface TimelineItem {
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
val safeSenderName: String = senderDisplayName ?: senderId.value
val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed

View File

@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -48,7 +49,7 @@ internal fun aMessageEvent(
id = eventId?.value.orEmpty(),
eventId = eventId,
senderId = A_USER_ID,
senderDisplayName = A_USER_NAME,
senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME),
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
content = content,
sentTime = "",

View File

@@ -82,7 +82,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemTextContent(
@@ -100,7 +100,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemLocationContent(
@@ -116,7 +116,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "", null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemTextContent(
@@ -134,7 +134,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = TextMessageType("body", null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemTextContent(
@@ -152,7 +152,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
) as TimelineItemTextContent
val expected = TimelineItemTextContent(
@@ -200,7 +200,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
@@ -218,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
@@ -229,7 +229,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVideoContent(
@@ -277,7 +277,7 @@ class TimelineItemContentMessageFactoryTest {
),
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVideoContent(
@@ -303,7 +303,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = AudioMessageType("body", MediaSource("url"), null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAudioContent(
@@ -332,7 +332,7 @@ class TimelineItemContentMessageFactoryTest {
)
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAudioContent(
@@ -351,7 +351,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVoiceContent(
@@ -384,7 +384,7 @@ class TimelineItemContentMessageFactoryTest {
),
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemVoiceContent(
@@ -409,7 +409,7 @@ class TimelineItemContentMessageFactoryTest {
)
val result = sut.create(
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemAudioContent(
@@ -428,7 +428,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
@@ -499,7 +499,7 @@ class TimelineItemContentMessageFactoryTest {
)
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemImageContent(
@@ -524,7 +524,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = FileMessageType("body", MediaSource("url"), null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemFileContent(
@@ -559,7 +559,7 @@ class TimelineItemContentMessageFactoryTest {
)
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemFileContent(
@@ -578,7 +578,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = NoticeMessageType("body", null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemNoticeContent(
@@ -601,7 +601,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
@@ -612,7 +612,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = EmoteMessageType("body", null)),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
val expected = TimelineItemEmoteContent(
@@ -635,7 +635,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)
),
senderDisplayName = "Bob",
senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))

View File

@@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
@@ -39,7 +40,7 @@ class TimelineItemGrouperTest {
id = "0",
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
senderDisplayName = "",
senderProfile = aProfileTimelineDetailsReady(displayName = ""),
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),

View File

@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import org.junit.Test
class InReplyToDetailTest {
@@ -54,8 +55,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderDisplayName = "senderDisplayName",
senderAvatarUrl = "senderAvatarUrl",
senderProfile = aProfileTimelineDetails(),
content = RoomMembershipContent(
userId = A_USER_ID,
change = MembershipChange.INVITED,
@@ -73,8 +73,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderDisplayName = "senderDisplayName",
senderAvatarUrl = "senderAvatarUrl",
senderProfile = aProfileTimelineDetails(),
content = MessageContent(
body = "**Hello!**",
inReplyTo = null,
@@ -101,8 +100,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderDisplayName = "senderDisplayName",
senderAvatarUrl = "senderAvatarUrl",
senderProfile = aProfileTimelineDetails(),
content = MessageContent(
body = "**Hello!**",
inReplyTo = null,

View File

@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@@ -55,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@@ -430,15 +432,13 @@ class InReplyToMetadataKtTest {
fun anInReplyToDetails(
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID,
senderDisplayName: String? = "senderDisplayName",
senderAvatarUrl: String? = "senderAvatarUrl",
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
eventContent: EventContent? = aMessageContent(),
textContent: String? = "textContent",
) = InReplyToDetails(
eventId = eventId,
senderId = senderId,
senderDisplayName = senderDisplayName,
senderAvatarUrl = senderAvatarUrl,
senderProfile = senderProfile,
eventContent = eventContent,
textContent = textContent,
)

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.migration.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
interface MigrationEntryPoint {
@Composable
fun present(): MigrationState
@Composable
fun Render(
state: MigrationState,
modifier: Modifier,
)
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.api
import io.element.android.libraries.architecture.AsyncData
data class MigrationState(
val migrationAction: AsyncData<Unit>,
)

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.migration.impl"
}
dependencies {
implementation(projects.features.migration.api)
implementation(projects.libraries.architecture)
implementation(libs.androidx.datastore.preferences)
implementation(projects.features.rageshake.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test)
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.api.MigrationState
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultMigrationEntryPoint @Inject constructor(
private val migrationPresenter: MigrationPresenter,
) : MigrationEntryPoint {
@Composable
override fun present(): MigrationState = migrationPresenter.present()
@Composable
override fun Render(
state: MigrationState,
modifier: Modifier,
) = MigrationView(
migrationState = state,
modifier = modifier,
)
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_migration")
private val applicationMigrationVersion = intPreferencesKey("applicationMigrationVersion")
@ContributesBinding(AppScope::class)
class DefaultMigrationStore @Inject constructor(
@ApplicationContext context: Context,
) : MigrationStore {
private val store = context.dataStore
override suspend fun setApplicationMigrationVersion(version: Int) {
store.edit { prefs ->
prefs[applicationMigrationVersion] = version
}
}
override fun applicationMigrationVersion(): Flow<Int> {
return store.data.map { prefs ->
prefs[applicationMigrationVersion] ?: 0
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.api.MigrationState
import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class MigrationPresenter @Inject constructor(
private val migrationStore: MigrationStore,
private val logFilesRemover: LogFilesRemover,
) : Presenter<MigrationState> {
@Composable
override fun present(): MigrationState {
val migrationStoreVersion = migrationStore.applicationMigrationVersion().collectAsState(initial = null)
var migrationAction: AsyncData<Unit> by remember { mutableStateOf(AsyncData.Uninitialized) }
/*
// Uncomment this block to run the migration everytime
LaunchedEffect(Unit) {
migrationStore.setApplicationMigrationVersion(0)
}
*/
LaunchedEffect(migrationStoreVersion.value) {
val migrationValue = migrationStoreVersion.value ?: return@LaunchedEffect
if (migrationValue == MIGRATION_VERSION) {
migrationAction = AsyncData.Success(Unit)
return@LaunchedEffect
}
migrationAction = AsyncData.Loading(Unit)
if (migrationValue < 1) {
logFilesRemover.perform()
}
// Add new step here
migrationStore.setApplicationMigrationVersion(MIGRATION_VERSION)
}
return MigrationState(
migrationAction = migrationAction,
)
}
companion object {
// Increment this value when you need to run the migration again, and
// add step in the LaunchedEffect above
const val MIGRATION_VERSION = 1
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.api.MigrationState
import io.element.android.libraries.architecture.AsyncData
internal class MigrationStateProvider : PreviewParameterProvider<MigrationState> {
override val values: Sequence<MigrationState>
get() = sequenceOf(
aMigrationState(),
aMigrationState(migrationAction = AsyncData.Loading(Unit)),
)
}
internal fun aMigrationState(
migrationAction: AsyncData<Unit> = AsyncData.Uninitialized,
) = MigrationState(
migrationAction = migrationAction,
)

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import kotlinx.coroutines.flow.Flow
interface MigrationStore {
/**
* Return of flow of the current value for application migration version.
* If the value is not set, it will emit 0.
* If the emitted value is lower than the current application migration version, it means
* that a migration should occur, and at the end [setApplicationMigrationVersion] should be called.
*/
fun applicationMigrationVersion(): Flow<Int>
/**
* Set the application migration version, typically after a migration has been done.
*/
suspend fun setApplicationMigrationVersion(version: Int)
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.features.api.MigrationState
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.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun MigrationView(
migrationState: MigrationState,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator()
if (migrationState.migrationAction.isLoading()) {
Text(text = stringResource(id = CommonStrings.common_please_wait))
}
}
}
}
@PreviewsDayNight
@Composable
internal fun MigrationViewPreview(
@PreviewParameter(MigrationStateProvider::class) state: MigrationState,
) = ElementPreview {
MigrationView(
migrationState = state,
)
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryMigrationStore(
initialApplicationMigrationVersion: Int = 0
) : MigrationStore {
private val applicationMigrationVersion = MutableStateFlow(initialApplicationMigrationVersion)
override suspend fun setApplicationMigrationVersion(version: Int) {
applicationMigrationVersion.value = version
}
override fun applicationMigrationVersion(): Flow<Int> {
return applicationMigrationVersion
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.migration.impl
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.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class MigrationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest {
val store = InMemoryMigrationStore(MigrationPresenter.MIGRATION_VERSION)
val presenter = createPresenter(
migrationStore = store,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit))
}
}
}
@Test
fun `present - testing all migrations`() = runTest {
val store = InMemoryMigrationStore(0)
val logFilesRemoverLambda = lambdaRecorder { -> }
val presenter = createPresenter(
migrationStore = store,
logFilesRemover = FakeLogFilesRemover(logFilesRemoverLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized)
awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Loading(Unit))
}
awaitItem().also { state ->
assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit))
}
logFilesRemoverLambda.assertions().isCalledExactly(1)
assertThat(store.applicationMigrationVersion().first()).isEqualTo(MigrationPresenter.MIGRATION_VERSION)
}
}
}
private fun createPresenter(
migrationStore: MigrationStore = InMemoryMigrationStore(0),
logFilesRemover: LogFilesRemover = FakeLogFilesRemover(lambdaRecorder(ensureNeverCalled = true) { -> }),
) = MigrationPresenter(
migrationStore = migrationStore,
logFilesRemover = logFilesRemover,
)

View File

@@ -202,7 +202,7 @@ private fun OnBoardingButtons(
@PreviewsDayNight
@Composable
internal fun OnBoardingScreenPreview(
internal fun OnBoardingViewPreview(
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview {
OnBoardingView(

View File

@@ -132,7 +132,7 @@ internal fun PollAnswerView(
@PreviewsDayNight
@Composable
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
internal fun PollAnswerViewDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = false),
)
@@ -140,7 +140,7 @@ internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
internal fun PollAnswerViewDisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = true),
)
@@ -148,7 +148,7 @@ internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
internal fun PollAnswerViewUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = false, isSelected = false),
)
@@ -156,7 +156,7 @@ internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
internal fun PollAnswerViewUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = false, isSelected = true),
)
@@ -164,7 +164,7 @@ internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
internal fun PollAnswerViewEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = false, isEnabled = false, isWinner = true),
)
@@ -172,7 +172,7 @@ internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
internal fun PollAnswerViewEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = true),
)
@@ -180,7 +180,7 @@ internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
internal fun PollAnswerViewEndedSelectedPreview() = ElementPreview {
PollAnswerView(
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false),
)

View File

@@ -241,7 +241,7 @@ private fun CreatorView(
@PreviewsDayNight
@Composable
internal fun PollContentUndisclosedPreview() = ElementPreview {
internal fun PollContentViewUndisclosedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -258,7 +258,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentDisclosedPreview() = ElementPreview {
internal fun PollContentViewDisclosedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -275,7 +275,7 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentEndedPreview() = ElementPreview {
internal fun PollContentViewEndedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -292,7 +292,7 @@ internal fun PollContentEndedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentCreatorEditablePreview() = ElementPreview {
internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -309,7 +309,7 @@ internal fun PollContentCreatorEditablePreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentCreatorPreview() = ElementPreview {
internal fun PollContentViewCreatorPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
@@ -326,7 +326,7 @@ internal fun PollContentCreatorPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentCreatorEndedPreview() = ElementPreview {
internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",

View File

@@ -25,22 +25,36 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
override val values: Sequence<DeveloperSettingsState>
get() = sequenceOf(
aDeveloperSettingsState(),
aDeveloperSettingsState().copy(clearCacheAction = AsyncData.Loading()),
aDeveloperSettingsState().copy(
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
aDeveloperSettingsState(
clearCacheAction = AsyncData.Loading()
),
aDeveloperSettingsState(
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
baseUrl = "https://call.element.ahoy",
defaultUrl = "https://call.element.io",
validator = { true }
)
),
)
}
fun aDeveloperSettingsState() = DeveloperSettingsState(
fun aDeveloperSettingsState(
clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized,
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState(
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
cacheSize = AsyncData.Success("1.2 MB"),
clearCacheAction = AsyncData.Uninitialized,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(baseUrl = null, defaultUrl = "https://call.element.io", validator = { true }),
eventSink = {}
clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState,
eventSink = eventSink,
)
fun aCustomElementCallBaseUrlState(
baseUrl: String? = null,
defaultUrl: String = "https://call.element.io",
validator: (String?) -> Boolean = { true },
) = CustomElementCallBaseUrlState(
baseUrl = baseUrl,
defaultUrl = defaultUrl,
validator = validator,
)

View File

@@ -42,16 +42,21 @@ private fun anEditDefaultNotificationSettingsState(
) = EditDefaultNotificationSettingState(
isOneToOne = isOneToOne,
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
roomsWithUserDefinedMode = persistentListOf(aRoomSummary()),
roomsWithUserDefinedMode = persistentListOf(
aRoomSummary("Room"),
aRoomSummary(null),
),
changeNotificationSettingAction = changeNotificationSettingAction,
displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer,
eventSink = {}
)
private fun aRoomSummary() = RoomSummary.Filled(
private fun aRoomSummary(
name: String?,
) = RoomSummary.Filled(
aRoomSummaryDetails(
roomId = RoomId("!roomId:domain"),
name = "Room",
name = name,
avatarUrl = null,
isDirect = false,
lastMessage = null,

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -100,7 +101,11 @@ fun EditDefaultNotificationSettingView(
)
ListItem(
headlineContent = {
Text(text = summary.details.name)
val roomName = summary.details.name
Text(
text = roomName ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { roomName == null }
)
},
supportingContent = {
Text(text = subtitle)

View File

@@ -0,0 +1,127 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.developer
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class DeveloperSettingsViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
ensureCalledOnce {
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
onBackPressed = it
)
rule.pressBack()
}
}
@Test
fun `clicking on element call url open the dialogs and submit emits the expected event`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.io"))
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on open showkase invokes the expected callback`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
ensureCalledOnce {
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
onOpenShowkase = it
)
rule.onNodeWithText("Open Showkase browser").performClick()
}
}
@Test
fun `clicking on configure tracing invokes the expected callback`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
ensureCalledOnce {
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
onOpenConfigureTracing = it
)
rule.onNodeWithText("Configure tracing").performClick()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on clear cache emits the expected event`() {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
rule.setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
rule.onNodeWithText("Clear cache").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeveloperSettingsView(
state: DeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onOpenConfigureTracing: () -> Unit = EnsureNeverCalled(),
onBackPressed: () -> Unit = EnsureNeverCalled()
) {
setContent {
DeveloperSettingsView(
state = state,
onOpenShowkase = onOpenShowkase,
onOpenConfigureTracing = onOpenConfigureTracing,
onBackPressed = onBackPressed,
)
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.api.logs
interface LogFilesRemover {
suspend fun perform()
}

View File

@@ -26,10 +26,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.impl.logs.VectorFileLogger
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
@@ -40,6 +40,7 @@ class BugReportPresenter @Inject constructor(
private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
private val logFilesRemover: LogFilesRemover,
private val appCoroutineScope: CoroutineScope,
) : Presenter<BugReportState> {
private class BugReporterUploadListener(
@@ -150,6 +151,6 @@ class BugReportPresenter @Inject constructor(
private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset()
crashDataStore.reset()
VectorFileLogger.getFromTimber()?.reset()
logFilesRemover.perform()
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.logs
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.impl.reporter.DefaultBugReporter
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLogFilesRemover @Inject constructor(
private val bugReporter: DefaultBugReporter,
) : LogFilesRemover {
override suspend fun perform() {
bugReporter.deleteAllFiles()
}
}

View File

@@ -1,63 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.logs
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.logging.Formatter
import java.util.logging.LogRecord
internal class LogFormatter : Formatter() {
override fun format(r: LogRecord): String {
if (!isTimeZoneSet) {
DATE_FORMAT.timeZone = TimeZone.getTimeZone("UTC")
isTimeZoneSet = true
}
val thrown = r.thrown
if (thrown != null) {
val sw = StringWriter()
val pw = PrintWriter(sw)
sw.write(r.message)
sw.write(LINE_SEPARATOR)
thrown.printStackTrace(pw)
pw.flush()
return sw.toString()
} else {
val b = StringBuilder()
val date = DATE_FORMAT.format(Date(r.millis))
b.append(date)
b.append("Z ")
b.append(r.message)
b.append(LINE_SEPARATOR)
return b.toString()
}
}
companion object {
private val LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n"
// private val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss*SSSZZZZ", Locale.US)
private var isTimeZoneSet = false
}
}

View File

@@ -1,176 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.logs
import android.content.Context
import android.util.Log
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.data.tryOrNull
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.util.logging.FileHandler
import java.util.logging.Level
import java.util.logging.Logger
/**
* Will be planted in Timber.
*/
class VectorFileLogger(
private val context: Context,
// private val vectorPreferences: VectorPreferences
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : Timber.Tree() {
companion object {
fun getFromTimber(): VectorFileLogger? {
return Timber.forest().filterIsInstance<VectorFileLogger>().firstOrNull()
}
private const val SIZE_20MB = 20 * 1024 * 1024
// private const val SIZE_50MB = 50 * 1024 * 1024
}
/*
private val maxLogSizeByte = if (vectorPreferences.labAllowedExtendedLogging()) SIZE_50MB else SIZE_20MB
private val logRotationCount = if (vectorPreferences.labAllowedExtendedLogging()) 15 else 7
*/
private val maxLogSizeByte = SIZE_20MB
private val logRotationCount = 7
private val logger = Logger.getLogger(context.packageName).apply {
tryOrNull {
useParentHandlers = false
level = Level.ALL
}
}
private val fileHandler: FileHandler?
private val cacheDirectory get() = File(context.cacheDir, "logs").apply {
if (!exists()) mkdirs()
}
private var fileNamePrefix = "logs"
private val prioPrefixes = mapOf(
Log.VERBOSE to "V/ ",
Log.DEBUG to "D/ ",
Log.INFO to "I/ ",
Log.WARN to "W/ ",
Log.ERROR to "E/ ",
Log.ASSERT to "WTF/ "
)
init {
for (i in 0..15) {
val file = File(cacheDirectory, "elementLogs.$i.txt")
file.safeDelete()
}
fileHandler = tryOrNull(
onError = { Timber.e(it, "Failed to initialize FileLogger") }
) {
FileHandler(
cacheDirectory.absolutePath + "/" + fileNamePrefix + ".%g.txt",
maxLogSizeByte,
logRotationCount
)
.also { it.formatter = LogFormatter() }
.also { logger.addHandler(it) }
}
}
fun reset() {
// Delete all files
getLogFiles().map {
it.safeDelete()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
fileHandler ?: return
GlobalScope.launch(dispatcher) {
if (skipLog(priority)) return@launch
if (t != null) {
logToFile(t)
}
logToFile(prioPrefixes[priority] ?: "$priority ", tag ?: "Tag", message)
}
}
private fun skipLog(priority: Int): Boolean {
// return if (vectorPreferences.labAllowedExtendedLogging()) {
// false
// } else {
// // Exclude verbose logs
// priority < Log.DEBUG
// }
// Exclude verbose logs
return priority < Log.DEBUG
}
/**
* Adds our own log files to the provided list of files.
*
* @return The list of files with logs.
*/
private fun getLogFiles(): List<File> {
return tryOrNull(
onError = { Timber.e(it, "## getLogFiles() failed") }
) {
fileHandler
?.flush()
?.let { 0 until logRotationCount }
?.mapNotNull { index ->
File(cacheDirectory, "$fileNamePrefix.$index.txt")
.takeIf { it.exists() }
}
}
.orEmpty()
}
/**
* Log an Throwable.
*
* @param throwable the throwable to log
*/
private fun logToFile(throwable: Throwable?) {
throwable ?: return
val errors = StringWriter()
throwable.printStackTrace(PrintWriter(errors))
logger.info(errors.toString())
}
private fun logToFile(level: String, tag: String, content: String) {
val b = StringBuilder()
b.append(Thread.currentThread().id)
b.append(" ")
b.append(level)
b.append("/")
b.append(tag)
b.append(": ")
b.append(content)
logger.info(b.toString())
}
}

View File

@@ -346,6 +346,12 @@ class DefaultBugReporter @Inject constructor(
}
}
suspend fun deleteAllFiles() {
withContext(coroutineDispatchers.io) {
getLogFiles().forEach { it.safeDelete() }
}
}
override fun setCurrentTracingFilter(tracingFilter: String) {
currentTracingFilter = tracingFilter
}
@@ -374,7 +380,6 @@ class DefaultBugReporter @Inject constructor(
/**
* Delete all the log files except the most recent one.
*
*/
private fun List<File>.deleteAllExceptMostRecent() {
if (size > 1) {

View File

@@ -21,15 +21,18 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -117,9 +120,11 @@ class BugReportPresenterTest {
@Test
fun `present - reset all`() = runTest {
val logFilesRemoverLambda = lambdaRecorder { -> }
val presenter = createPresenter(
crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
logFilesRemover = FakeLogFilesRemover(logFilesRemoverLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -131,6 +136,7 @@ class BugReportPresenterTest {
initialState.eventSink.invoke(BugReportEvents.ResetAll)
val resetState = awaitItem()
assertThat(resetState.hasCrashLogs).isFalse()
logFilesRemoverLambda.assertions().isCalledExactly(1)
// TODO Make it live assertThat(resetState.screenshotUri).isNull()
}
}
@@ -239,10 +245,12 @@ class BugReportPresenterTest {
bugReporter: BugReporter = FakeBugReporter(),
crashDataStore: CrashDataStore = FakeCrashDataStore(),
screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(),
logFilesRemover: LogFilesRemover = FakeLogFilesRemover(lambdaRecorder(ensureNeverCalled = true) { -> }),
) = BugReportPresenter(
bugReporter,
crashDataStore,
screenshotHolder,
this,
bugReporter = bugReporter,
crashDataStore = crashDataStore,
screenshotHolder = screenshotHolder,
logFilesRemover = logFilesRemover,
appCoroutineScope = this,
)
}

View File

@@ -1,58 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.impl.logs
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class VectorFileLoggerTest {
@Test
fun `init VectorFileLogger log debug`() = runTest {
val sut = createVectorFileLogger()
sut.d("A debug log")
}
@Test
fun `init VectorFileLogger log error`() = runTest {
val sut = createVectorFileLogger()
sut.e(A_THROWABLE, "A debug log")
}
@Test
fun `reset VectorFileLogger`() = runTest {
val sut = createVectorFileLogger()
sut.reset()
}
@Test
fun `check getFromTimber`() {
assertThat(VectorFileLogger.getFromTimber()).isNull()
}
private fun TestScope.createVectorFileLogger() = VectorFileLogger(
context = RuntimeEnvironment.getApplication(),
dispatcher = testCoroutineDispatchers().io,
)
}

View File

@@ -24,4 +24,5 @@ android {
dependencies {
implementation(projects.features.rageshake.api)
implementation(libs.coroutines.core)
implementation(projects.tests.testutils)
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.rageshake.test.logs
import io.element.android.features.rageshake.api.logs.LogFilesRemover
import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
class FakeLogFilesRemover(
private val performLambda: LambdaNoParamRecorder<Unit>,
) : LogFilesRemover {
override suspend fun perform() {
performLambda()
}
}

View File

@@ -23,6 +23,11 @@ plugins {
android {
namespace = "io.element.android.features.roomaliasresolver.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
anvil {
@@ -44,10 +49,13 @@ dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}

View File

@@ -17,8 +17,10 @@
package io.element.android.features.roomaliasresolver.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -36,6 +39,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.LightGradientBackground
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -61,22 +65,26 @@ fun RoomAliasResolverView(
latestOnAliasResolved(state.resolveState.data)
}
}
HeaderFooterPage(
modifier = modifier,
paddingValues = PaddingValues(16.dp),
topBar = {
RoomAliasResolverTopBar(onBackClicked = onBackPressed)
},
content = {
RoomAliasResolverContent(state = state)
},
footer = {
RoomAliasResolverFooter(
state = state,
)
}
)
Box(
modifier = modifier.fillMaxSize(),
) {
LightGradientBackground()
HeaderFooterPage(
containerColor = Color.Transparent,
paddingValues = PaddingValues(16.dp),
topBar = {
RoomAliasResolverTopBar(onBackClicked = onBackPressed)
},
content = {
RoomAliasResolverContent(state = state)
},
footer = {
RoomAliasResolverFooter(
state = state,
)
}
)
}
}
@Composable
@@ -92,7 +100,7 @@ private fun RoomAliasResolverFooter(
state.eventSink(RoomAliasResolverEvents.Retry)
},
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium,
size = ButtonSize.Large,
)
}
is AsyncData.Loading -> {

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_alias_resolver_resolve_alias_failure">"Не ўдалося разабрацца з псеўданімам пакоя."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_alias_resolver_resolve_alias_failure">"Nem sikerült a szoba álnevének feloldása."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_alias_resolver_resolve_alias_failure">"Не удалось определить псевдоним комнаты."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_alias_resolver_resolve_alias_failure">"Nepodarilo sa nájsť alias miestnosti."</string>
</resources>

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomaliasresolver.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomAliasResolverViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomAliasResolverEvents>(expectEvents = false)
ensureCalledOnce {
rule.setRoomAliasResolverView(
aRoomAliasResolverState(
eventSink = eventsRecorder,
),
onBackPressed = it
)
rule.pressBack()
}
}
@Test
fun `clicking on Retry emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomAliasResolverEvents>()
rule.setRoomAliasResolverView(
aRoomAliasResolverState(
resolveState = AsyncData.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(RoomAliasResolverEvents.Retry)
}
@Test
fun `success state invokes the expected Callback`() {
val eventsRecorder = EventsRecorder<RoomAliasResolverEvents>(expectEvents = false)
ensureCalledOnceWithParam(A_ROOM_ID) {
rule.setRoomAliasResolverView(
aRoomAliasResolverState(
resolveState = AsyncData.Success(A_ROOM_ID),
eventSink = eventsRecorder,
),
onAliasResolved = it,
)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomAliasResolverView(
state: RoomAliasResolverState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onAliasResolved: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
RoomAliasResolverView(
state = state,
onBackPressed = onBackPressed,
onAliasResolved = onAliasResolved,
)
}
}

View File

@@ -225,7 +225,7 @@ private fun RoomInviteMembersSearchBar(
@PreviewsDayNight
@Composable
internal fun RoomInviteMembersPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
RoomInviteMembersView(
state = state,
onBackPressed = {},

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