Merge branch 'develop' into feature/fga/permalink_timeline

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

View file

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

View file

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

View file

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

View file

@ -17,10 +17,9 @@
package io.element.android.appnav.room package io.element.android.appnav.room
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable 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.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext 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.anvilannotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode 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.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.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView 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.NodeInputs
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs 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.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomAlias
@ -69,6 +71,7 @@ class RoomFlowNode @AssistedInject constructor(
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val joinRoomEntryPoint: JoinRoomEntryPoint, private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val networkMonitor: NetworkMonitor,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
@ -125,7 +128,15 @@ class RoomFlowNode @AssistedInject constructor(
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}") Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
val info = roomInfo.getOrNull() val info = roomInfo.getOrNull()
if (info?.currentUserMembership == CurrentUserMembership.JOINED) { 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 { } else {
backstack.newRoot(NavTarget.JoinRoom(roomId)) backstack.newRoot(NavTarget.JoinRoom(roomId))
} }
@ -175,10 +186,14 @@ class RoomFlowNode @AssistedInject constructor(
} }
} }
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { val networkStatus by networkMonitor.connectivity.collectAsState()
CircularProgressIndicator() LoadingRoomNodeView(
} state = LoadingRoomState.Loading,
hasNetworkConnection = networkStatus == NetworkStatus.Online,
onBackClicked = { navigateUp() },
modifier = modifier,
)
} }
@Composable @Composable

View file

@ -68,7 +68,7 @@ fun RootView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RootPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview { internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
RootView( RootView(
state = rootState, state = rootState,
onOpenBugReport = {}, 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.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage 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.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
@ -60,6 +61,7 @@ dependencies {
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl) testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils) 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()) { when (ftueState.getNextStep()) {
FtueStep.SessionVerification -> { FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.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.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage 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.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle 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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData

View file

@ -58,9 +58,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
} }
interface Callback : Plugin { interface Callback : Plugin {
@ -68,10 +65,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
} }
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback { private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() { override fun onDone() {
lifecycleScope.launch { lifecycleScope.launch {
// Move to the completed state view in the verification flow // Move to the completed state view in the verification flow
@ -89,10 +82,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
backstack.push(NavTarget.EnterRecoveryKey) backstack.push(NavTarget.EnterRecoveryKey)
} }
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() { override fun onDone() {
plugins<Callback>().forEach { it.onDone() } plugins<Callback>().forEach { it.onDone() }
} }
@ -105,12 +94,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
.callback(secureBackupEntryPointCallback) .callback(secureBackupEntryPointCallback)
.build() .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.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService 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.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService 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.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultFtueService @Inject constructor( class DefaultFtueService @Inject constructor(
@ -44,6 +52,7 @@ class DefaultFtueService @Inject constructor(
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService, private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService { ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown) override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
@ -55,7 +64,7 @@ class DefaultFtueService @Inject constructor(
} }
init { init {
sessionVerificationService.needsVerificationFlow sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() } .onEach { updateState() }
.launchIn(coroutineScope) .launchIn(coroutineScope)
@ -64,7 +73,7 @@ class DefaultFtueService @Inject constructor(
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
fun getNextStep(currentStep: FtueStep? = null): FtueStep? = suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (isSessionNotVerified()) { null -> if (isSessionNotVerified()) {
FtueStep.SessionVerification FtueStep.SessionVerification
@ -89,8 +98,8 @@ class DefaultFtueService @Inject constructor(
FtueStep.AnalyticsOptIn -> null FtueStep.AnalyticsOptIn -> null
} }
private fun isAnyStepIncomplete(): Boolean { private suspend fun isAnyStepIncomplete(): Boolean {
return listOf( return listOf<suspend () -> Boolean>(
{ isSessionNotVerified() }, { isSessionNotVerified() },
{ shouldAskNotificationPermissions() }, { shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() }, { needsAnalyticsOptIn() },
@ -98,16 +107,28 @@ class DefaultFtueService @Inject constructor(
).any { it() } ).any { it() }
} }
private fun isSessionNotVerified(): Boolean { @OptIn(FlowPreview::class)
return sessionVerificationService.needsVerificationFlow.value 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 // 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)) { return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() } val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
@ -118,14 +139,12 @@ class DefaultFtueService @Inject constructor(
} }
} }
private fun shouldDisplayLockscreenSetup(): Boolean { private suspend fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking { return lockScreenService.isSetupRequired().first()
lockScreenService.isSetupRequired().first()
}
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() { internal suspend fun updateState() {
state.value = when { state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete 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_retry_button">"Паўтарыць спробу"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Няправільны QR-код"</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_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_no_camera_permission_state_title">"Дазвольце доступ да камеры для сканавання QR-кода"</string>
<string name="screen_qr_code_login_scanning_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_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_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_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_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_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_scanning_state_title">"Naskenujte QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začít znovu"</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_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_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_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_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_2">"Klick auf deinen Avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</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_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_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_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_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_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_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_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_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_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> <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_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_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_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_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_2">"Kattintson a profilképére"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</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_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_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_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_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_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_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_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_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_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> <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_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_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_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_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_2">"Klik pada avatar Anda"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</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_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_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_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_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_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_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> <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_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_list_item_3">"Если это не помогло, войдите вручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</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_1">"Откройте %1$s на настольном устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</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> <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_retry_button">"Повторить попытку"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неверный QR-код"</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_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_no_camera_permission_state_title">"Разрешите доступ к камере для сканирования QR-кода"</string>
<string name="screen_qr_code_login_scanning_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_start_over_button">"Начать заново"</string>
<string name="screen_qr_code_login_unknown_error_description">"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."</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_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string> <string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</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_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_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_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_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_scanning_state_title">"Naskenovať QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začať odznova"</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_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_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_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_subtitle">"Youll be asked to enter the two digits shown on this device."</string>
<string name="screen_qr_code_login_device_code_title">"Enter number on your 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_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_2">"Click on your avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</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_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_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_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_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_scanning_state_title">"Scan the QR code"</string>
<string name="screen_qr_code_login_start_over_button">"Start over"</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.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService 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.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
@ -90,7 +91,6 @@ class DefaultFtueServiceTests {
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@ -108,7 +108,7 @@ class DefaultFtueServiceTests {
// Session verification // Session verification
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenNeedsVerification(false) sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in // Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
@ -200,6 +200,7 @@ class DefaultFtueServiceTests {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false), permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(), lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required // First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService( ) = DefaultFtueService(
@ -209,5 +210,6 @@ class DefaultFtueServiceTests {
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, 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 { private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncAction<RoomId>>) = launch {
acceptedAction.runUpdatingState { acceptedAction.runUpdatingState {
client.joinRoom(roomId).onSuccess { client.joinRoom(roomId)
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) .onSuccess {
client.getRoom(roomId)?.use { room -> notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) client.getRoom(roomId)?.use { room ->
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
}
} }
} .map { roomId }
} }
} }

View file

@ -164,7 +164,7 @@ class AcceptDeclineInvitePresenterTest {
@Test @Test
fun `present - accepting invite error flow`() = runTest { fun `present - accepting invite error flow`() = runTest {
val joinRoomFailure = lambdaRecorder { roomId: RoomId -> 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 { val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomFailure joinRoomLambda = joinRoomFailure
@ -197,8 +197,8 @@ class AcceptDeclineInvitePresenterTest {
@Test @Test
fun `present - accepting invite success flow`() = runTest { fun `present - accepting invite success flow`() = runTest {
val joinRoomSuccess = lambdaRecorder { roomId: RoomId -> val joinRoomSuccess = lambdaRecorder { _: RoomId ->
Result.success(roomId) Result.success(Unit)
} }
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomSuccess joinRoomLambda = joinRoomSuccess

View file

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

View file

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

View file

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

View file

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

View file

@ -18,16 +18,21 @@ package io.element.android.features.joinroom.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId 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.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
@Immutable @Immutable
data class JoinRoomState( data class JoinRoomState(
val contentState: ContentState, val contentState: ContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState, val acceptDeclineInviteState: AcceptDeclineInviteState,
val knockAction: AsyncAction<Unit>,
val applicationName: String,
val eventSink: (JoinRoomEvents) -> Unit val eventSink: (JoinRoomEvents) -> Unit
) { ) {
val joinAuthorisationStatus = when (contentState) { val joinAuthorisationStatus = when (contentState) {
@ -47,17 +52,10 @@ sealed interface ContentState {
val alias: RoomAlias?, val alias: RoomAlias?,
val numberOfMembers: Long?, val numberOfMembers: Long?,
val isDirect: Boolean, val isDirect: Boolean,
val roomType: RoomType,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
val joinAuthorisationStatus: JoinAuthorisationStatus, val joinAuthorisationStatus: JoinAuthorisationStatus,
) : ContentState { ) : ContentState {
val computedTitle = name ?: roomId.value
val computedSubtitle = when {
alias != null -> alias.value
name == null -> ""
else -> roomId.value
}
val showMemberCount = numberOfMembers != null val showMemberCount = numberOfMembers != null
fun avatarData(size: AvatarSize): AvatarData { fun avatarData(size: AvatarSize): AvatarData {
@ -71,9 +69,9 @@ sealed interface ContentState {
} }
} }
enum class JoinAuthorisationStatus { sealed interface JoinAuthorisationStatus {
IsInvited, data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
CanKnock, data object CanKnock : JoinAuthorisationStatus
CanJoin, data object CanJoin : JoinAuthorisationStatus
Unknown, 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState 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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId 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.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.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> { open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
override val values: Sequence<JoinRoomState> override val values: Sequence<JoinRoomState>
@ -33,6 +39,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
aJoinRoomState( aJoinRoomState(
contentState = anUnknownContentState() contentState = anUnknownContentState()
), ),
aJoinRoomState(
contentState = aLoadedContentState(
name = null,
alias = null,
topic = null,
)
),
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin) contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
), ),
@ -48,7 +61,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
) )
), ),
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited) contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
),
aJoinRoomState(
contentState = aLoadedContentState(
numberOfMembers = 123,
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(anInviteSender()),
)
), ),
aJoinRoomState( aJoinRoomState(
contentState = aFailureContentState() contentState = aFailureContentState()
@ -56,6 +75,15 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
aJoinRoomState( aJoinRoomState(
contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias()) 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( fun aLoadedContentState(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String = "Element X android", name: String? = "Element X android",
alias: RoomAlias? = RoomAlias("#exa:matrix.org"), alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
topic: String? = "Element X is a secure, private and decentralized messenger.", topic: String? = "Element X is a secure, private and decentralized messenger.",
numberOfMembers: Long? = null, numberOfMembers: Long? = null,
isDirect: Boolean = false, isDirect: Boolean = false,
roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null, roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
) = ContentState.Loaded( ) = ContentState.Loaded(
@ -88,6 +117,7 @@ fun aLoadedContentState(
topic = topic, topic = topic,
numberOfMembers = numberOfMembers, numberOfMembers = numberOfMembers,
isDirect = isDirect, isDirect = isDirect,
roomType = roomType,
roomAvatarUrl = roomAvatarUrl, roomAvatarUrl = roomAvatarUrl,
joinAuthorisationStatus = joinAuthorisationStatus joinAuthorisationStatus = joinAuthorisationStatus
) )
@ -95,12 +125,25 @@ fun aLoadedContentState(
fun aJoinRoomState( fun aJoinRoomState(
contentState: ContentState = aLoadedContentState(), contentState: ContentState = aLoadedContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (JoinRoomEvents) -> Unit = {} eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState( ) = JoinRoomState(
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
knockAction = knockAction,
applicationName = "AppName",
eventSink = eventSink 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_ID = RoomId("!exa:matrix.org")
private val A_ROOM_ALIAS = RoomAlias("#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 package io.element.android.features.joinroom.impl
import androidx.compose.foundation.layout.Arrangement 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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom 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.molecules.RoomPreviewMembersCountMolecule
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage 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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun JoinRoomView( fun JoinRoomView(
state: JoinRoomState, state: JoinRoomState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onKnockSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
HeaderFooterPage( Box(
modifier = modifier, modifier = modifier.fillMaxSize(),
paddingValues = PaddingValues(16.dp), ) {
topBar = { LightGradientBackground()
JoinRoomTopBar(onBackClicked = onBackPressed) HeaderFooterPage(
}, containerColor = Color.Transparent,
content = { paddingValues = PaddingValues(16.dp),
JoinRoomContent(contentState = state.contentState) topBar = {
}, JoinRoomTopBar(onBackClicked = onBackPressed)
footer = { },
JoinRoomFooter( content = {
state = state, JoinRoomContent(
onAcceptInvite = { contentState = state.contentState,
state.eventSink(JoinRoomEvents.AcceptInvite) applicationName = state.applicationName,
}, )
onDeclineInvite = { },
state.eventSink(JoinRoomEvents.DeclineInvite) footer = {
}, JoinRoomFooter(
onJoinRoom = { state = state,
state.eventSink(JoinRoomEvents.JoinRoom) onAcceptInvite = {
}, state.eventSink(JoinRoomEvents.AcceptInvite)
onRetry = { },
state.eventSink(JoinRoomEvents.RetryFetchingContent) 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, onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit, onDeclineInvite: () -> Unit,
onJoinRoom: () -> Unit, onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onGoBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.contentState is ContentState.Failure) { if (state.contentState is ContentState.Failure) {
@ -97,41 +132,51 @@ private fun JoinRoomFooter(
text = stringResource(CommonStrings.action_retry), text = stringResource(CommonStrings.action_retry),
onClick = onRetry, onClick = onRetry,
modifier = modifier.fillMaxWidth(), 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 { } else {
val joinAuthorisationStatus = state.joinAuthorisationStatus val joinAuthorisationStatus = state.joinAuthorisationStatus
when (joinAuthorisationStatus) { when (joinAuthorisationStatus) {
JoinAuthorisationStatus.IsInvited -> { is JoinAuthorisationStatus.IsInvited -> {
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) { ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
OutlinedButton( OutlinedButton(
text = stringResource(CommonStrings.action_decline), text = stringResource(CommonStrings.action_decline),
onClick = onDeclineInvite, onClick = onDeclineInvite,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
size = ButtonSize.Medium, size = ButtonSize.Large,
) )
Button( Button(
text = stringResource(CommonStrings.action_accept), text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite, onClick = onAcceptInvite,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
size = ButtonSize.Medium, size = ButtonSize.Large,
) )
} }
} }
JoinAuthorisationStatus.CanJoin -> { JoinAuthorisationStatus.CanJoin -> {
Button( SuperButton(
text = stringResource(R.string.screen_join_room_join_action),
onClick = onJoinRoom, onClick = onJoinRoom,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium, buttonSize = ButtonSize.Large,
) ) {
Text(
text = stringResource(R.string.screen_join_room_join_action),
)
}
} }
JoinAuthorisationStatus.CanKnock -> { JoinAuthorisationStatus.CanKnock -> {
Button( Button(
text = stringResource(R.string.screen_join_room_knock_action), text = stringResource(R.string.screen_join_room_knock_action),
onClick = onJoinRoom, onClick = onKnockRoom,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium, size = ButtonSize.Large,
) )
} }
JoinAuthorisationStatus.Unknown -> Unit JoinAuthorisationStatus.Unknown -> Unit
@ -142,6 +187,7 @@ private fun JoinRoomFooter(
@Composable @Composable
private fun JoinRoomContent( private fun JoinRoomContent(
contentState: ContentState, contentState: ContentState,
applicationName: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (contentState) { when (contentState) {
@ -152,13 +198,48 @@ private fun JoinRoomContent(
Avatar(contentState.avatarData(AvatarSize.RoomHeader)) Avatar(contentState.avatarData(AvatarSize.RoomHeader))
}, },
title = { 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 = { subtitle = {
RoomPreviewSubtitleAtom(contentState.computedSubtitle) if (contentState.alias != null) {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
}, },
description = { 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 = { memberCount = {
if (contentState.showMemberCount) { if (contentState.showMemberCount) {
@ -213,7 +294,7 @@ private fun JoinRoomContent(
}, },
subtitle = { subtitle = {
Text( Text(
text = "Failed to get information about the room", text = stringResource(id = CommonStrings.error_unknown),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
@ -241,6 +322,7 @@ private fun JoinRoomTopBar(
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
JoinRoomView( JoinRoomView(
state = state, 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.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.Presenter 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.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
@ -35,7 +36,9 @@ object JoinRoomModule {
@Provides @Provides
fun providesJoinRoomPresenterFactory( fun providesJoinRoomPresenterFactory(
client: MatrixClient, client: MatrixClient,
knockRoom: KnockRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory {
override fun create( override fun create(
@ -48,7 +51,9 @@ object JoinRoomModule {
roomIdOrAlias = roomIdOrAlias, roomIdOrAlias = roomIdOrAlias,
roomDescription = roomDescription, roomDescription = roomDescription,
matrixClient = client, matrixClient = client,
knockRoom = knockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, 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"> <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_join_action">"Join room"</string>
<string name="screen_join_room_knock_action">"Knock to join"</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_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_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> <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.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState 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.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias 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.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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership 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.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME 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.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.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.WarmUpRule
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder 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.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.applicationName).isEqualTo("AppName")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -101,7 +110,31 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> 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 @Test
fun `present - when room is not known RoomPreview is loaded`() = runTest { fun `present - when room is not known RoomPreview is loaded`() = runTest {
val client = FakeMatrixClient( val client = FakeMatrixClient(
@ -254,7 +319,7 @@ class JoinRoomPresenterTest {
topic = "Room topic", topic = "Room topic",
avatarUrl = "avatarUrl", avatarUrl = "avatarUrl",
numberOfJoinedMembers = 2, numberOfJoinedMembers = 2,
roomType = null, roomType = RoomType.Room,
isHistoryWorldReadable = false, isHistoryWorldReadable = false,
isJoined = false, isJoined = false,
isInvited = false, isInvited = false,
@ -278,6 +343,7 @@ class JoinRoomPresenterTest {
alias = RoomAlias("#alias:matrix.org"), alias = RoomAlias("#alias:matrix.org"),
numberOfMembers = 2, numberOfMembers = 2,
isDirect = false, isDirect = false,
roomType = RoomType.Room,
roomAvatarUrl = "avatarUrl", roomAvatarUrl = "avatarUrl",
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
) )
@ -350,6 +416,8 @@ class JoinRoomPresenterTest {
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(), roomDescription: Optional<RoomDescription> = Optional.empty(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
knockRoom: KnockRoom = FakeKnockRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() } acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
): JoinRoomPresenter { ): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
@ -357,6 +425,8 @@ class JoinRoomPresenterTest {
roomIdOrAlias = roomId.toRoomIdOrAlias(), roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription, roomDescription = roomDescription,
matrixClient = matrixClient, matrixClient = matrixClient,
knockRoom = knockRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter 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 @Composable
@PreviewsDayNight @PreviewsDayNight
internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview { ElementPreview {
PinUnlockView( PinUnlockView(
state = state, state = state,
@ -369,7 +369,7 @@ internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider:
@Composable @Composable
@PreviewsDayNight @PreviewsDayNight
internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview { ElementPreview {
PinUnlockView( PinUnlockView(
state = state, 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">"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_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_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> <string name="screen_app_lock_signout_alert_title">"Du blir utloggad"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Du har %1$d försök att låsa upp"</item> <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( anActionListState().copy(
target = ActionListState.Target.Success( target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy( event = aTimelineItemEvent(
reactionsState = reactionsState content = aTimelineItemImageContent(),
displayNameAmbiguous = true,
).copy(
reactionsState = reactionsState,
), ),
displayEmojiReactions = true, displayEmojiReactions = true,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
@ -142,6 +145,7 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) )
} }
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> { fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
return persistentListOf( return persistentListOf(
TimelineItemAction.EndPoll, 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.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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() icon()
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row { SenderName(
if (event.senderDisplayName != null) { senderId = event.senderId,
Text( senderProfile = event.senderProfile,
text = event.senderDisplayName, senderNameMode = SenderNameMode.ActionList,
style = ElementTheme.typography.fontBodySmMedium, )
color = MaterialTheme.colorScheme.primary
)
}
}
content() content()
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))

View file

@ -141,7 +141,7 @@ private fun RoomMemberSuggestionItemView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun MentionSuggestionsPickerView_Preview() { internal fun MentionSuggestionsPickerViewPreview() {
ElementPreview { ElementPreview {
val roomMember = RoomMember( val roomMember = RoomMember(
userId = UserId("@alice:server.org"), 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 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.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
@ -130,6 +131,7 @@ internal fun aTimelineItemEvent(
isMine: Boolean = false, isMine: Boolean = false,
isEditable: Boolean = false, isEditable: Boolean = false,
senderDisplayName: String = "Sender", senderDisplayName: String = "Sender",
displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null, sendState: LocalEventSendState? = null,
@ -151,7 +153,10 @@ internal fun aTimelineItemEvent(
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
senderDisplayName = senderDisplayName, senderProfile = aProfileTimelineDetailsReady(
displayName = senderDisplayName,
displayNameAmbiguous = displayNameAmbiguous,
),
groupPosition = groupPosition, groupPosition = groupPosition,
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,

View file

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

View file

@ -69,6 +69,8 @@ import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent 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.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId 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.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.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -291,7 +295,8 @@ private fun TimelineItemEventRowContent(
val avatarStrokeSize = 3.dp val avatarStrokeSize = 3.dp
if (event.showSenderInformation && !timelineRoomInfo.isDm) { if (event.showSenderInformation && !timelineRoomInfo.isDm) {
MessageSenderInformation( MessageSenderInformation(
event.safeSenderName, event.senderId,
event.senderProfile,
event.senderAvatar, event.senderAvatar,
avatarStrokeSize, avatarStrokeSize,
Modifier Modifier
@ -371,7 +376,8 @@ private fun TimelineItemEventRowContent(
@Composable @Composable
private fun MessageSenderInformation( private fun MessageSenderInformation(
sender: String, senderId: UserId,
senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData, senderAvatar: AvatarData,
avatarStrokeSize: Dp, avatarStrokeSize: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -398,13 +404,10 @@ private fun MessageSenderInformation(
Row { Row {
Avatar(senderAvatar) Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( SenderName(
modifier = Modifier.clipToBounds(), senderId = senderId,
text = sender, senderProfile = senderProfile,
maxLines = 1, senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
overflow = TextOverflow.Ellipsis,
color = avatarColors.foreground,
style = ElementTheme.typography.fontBodyMdMedium,
) )
} }
} }
@ -561,10 +564,10 @@ private fun MessageEventBubbleContent(
} }
} }
val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
val topPadding = if (showThreadDecoration) 0.dp else 8.dp val topPadding = if (showThreadDecoration) 0.dp else 8.dp
ReplyToContent( ReplyToContent(
senderName = senderName, senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(), metadata = inReplyTo.metadata(),
modifier = Modifier modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
@ -609,7 +612,8 @@ private fun MessageEventBubbleContent(
@Composable @Composable
private fun ReplyToContent( private fun ReplyToContent(
senderName: String, senderId: UserId,
senderProfile: ProfileTimelineDetails,
metadata: InReplyToMetadata?, metadata: InReplyToMetadata?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -633,18 +637,15 @@ private fun ReplyToContent(
) )
Spacer(modifier = Modifier.width(8.dp)) 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) { Column(verticalArrangement = Arrangement.SpaceBetween) {
Text( SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Reply,
modifier = Modifier.semantics { modifier = Modifier.semantics {
contentDescription = a11InReplyToText contentDescription = a11InReplyToText
}, },
text = senderName,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
ReplyToContentText(metadata) 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, body = str,
), ),
reactionsState = aTimelineItemReactions(count = 0), 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.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType 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.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.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
@ -58,7 +59,10 @@ internal fun TimelineItemEventRowWithReplyPreview(
} }
@Composable @Composable
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) { internal fun TimelineItemEventRowWithReplyContentToPreview(
inReplyToDetails: InReplyToDetails,
displayNameAmbiguous: Boolean = false,
) {
Column { Column {
sequenceOf(false, true).forEach { sequenceOf(false, true).forEach {
ATimelineItemEventRow( ATimelineItemEventRow(
@ -69,6 +73,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
body = "A reply." body = "A reply."
), ),
inReplyTo = inReplyToDetails, inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
), ),
) )
@ -80,6 +85,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
aspectRatio = 2.5f aspectRatio = 2.5f
), ),
inReplyTo = inReplyToDetails, inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous,
isThreaded = true, isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
@ -150,7 +156,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
) )
} }
private fun aMessageContent( protected fun aMessageContent(
body: String, body: String,
type: MessageType, type: MessageType,
) = MessageContent( ) = MessageContent(
@ -163,12 +169,24 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
protected fun aInReplyToDetails( protected fun aInReplyToDetails(
eventContent: EventContent, eventContent: EventContent,
displayNameAmbiguous: Boolean = false,
) = InReplyToDetails( ) = InReplyToDetails(
eventId = EventId("\$event"), eventId = EventId("\$event"),
eventContent = eventContent, eventContent = eventContent,
senderId = UserId("@Sender:domain"), senderId = UserId("@Sender:domain"),
senderDisplayName = "Sender", senderProfile = aProfileTimelineDetailsReady(
senderAvatarUrl = null, displayNameAmbiguous = displayNameAmbiguous,
),
textContent = (eventContent as? MessageContent)?.body.orEmpty(), 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 @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemReactionsViewPreview( internal fun TimelineItemReadReceiptViewPreview(
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState, @PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
) = ElementPreview { ) = ElementPreview {
TimelineItemReadReceiptView( TimelineItemReadReceiptView(

View file

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

View file

@ -70,17 +70,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser, 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) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent( TimelineItemEmoteContent(
body = emoteBody, body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument( htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser, 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, 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.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember 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.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 io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat import java.text.DateFormat
@ -55,15 +55,14 @@ class TimelineItemEventFactory @Inject constructor(
val currentSender = currentTimelineItem.event.sender val currentSender = currentTimelineItem.event.sender
val groupPosition = val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index) computeGroupPosition(currentTimelineItem, timelineItems, index)
val (senderDisplayName, senderAvatarUrl) = currentTimelineItem.getSenderInfo() val senderProfile = currentTimelineItem.event.senderProfile
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp)) val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
val senderAvatarData = AvatarData( val senderAvatarData = AvatarData(
id = currentSender.value, id = currentSender.value,
name = senderDisplayName ?: currentSender.value, name = senderProfile.getDisambiguatedDisplayName(currentSender),
url = senderAvatarUrl, url = senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineSender size = AvatarSize.TimelineSender
) )
currentTimelineItem.event currentTimelineItem.event
@ -72,7 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
eventId = currentTimelineItem.eventId, eventId = currentTimelineItem.eventId,
transactionId = currentTimelineItem.transactionId, transactionId = currentTimelineItem.transactionId,
senderId = currentSender, senderId = currentSender,
senderDisplayName = senderDisplayName, senderProfile = senderProfile,
senderAvatar = senderAvatarData, senderAvatar = senderAvatarData,
content = contentFactory.create(currentTimelineItem.event), content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn, 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 { private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
var aggregatedReactions = event.reactions.map { reaction -> 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.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo 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.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.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText 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( data class InReplyToDetails(
val eventId: EventId, val eventId: EventId,
val senderId: UserId, val senderId: UserId,
val senderDisplayName: String?, val senderProfile: ProfileTimelineDetails,
val senderAvatarUrl: String?,
val eventContent: EventContent?, val eventContent: EventContent?,
val textContent: String?, val textContent: String?,
) )
@ -41,8 +41,7 @@ fun InReplyTo.map(
is InReplyTo.Ready -> InReplyToDetails( is InReplyTo.Ready -> InReplyToDetails(
eventId = eventId, eventId = eventId,
senderId = senderId, senderId = senderId,
senderDisplayName = senderDisplayName, senderProfile = senderProfile,
senderAvatarUrl = senderAvatarUrl,
eventContent = content, eventContent = content,
textContent = when (content) { textContent = when (content) {
is MessageContent -> { 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo 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.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.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
@ -65,7 +67,7 @@ sealed interface TimelineItem {
val eventId: EventId? = null, val eventId: EventId? = null,
val transactionId: TransactionId? = null, val transactionId: TransactionId? = null,
val senderId: UserId, val senderId: UserId,
val senderDisplayName: String?, val senderProfile: ProfileTimelineDetails,
val senderAvatar: AvatarData, val senderAvatar: AvatarData,
val content: TimelineItemEventContent, val content: TimelineItemEventContent,
val sentTime: String = "", val sentTime: String = "",
@ -82,7 +84,7 @@ sealed interface TimelineItem {
) : TimelineItem { ) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine val showSenderInformation = groupPosition.isNew() && !isMine
val safeSenderName: String = senderDisplayName ?: senderId.value val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed 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.aTimelineItemDebugInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions 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.InReplyToDetails
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData 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.TimelineItem
@ -48,7 +49,7 @@ internal fun aMessageEvent(
id = eventId?.value.orEmpty(), id = eventId?.value.orEmpty(),
eventId = eventId, eventId = eventId,
senderId = A_USER_ID, 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), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
content = content, content = content,
sentTime = "", sentTime = "",

View file

@ -82,7 +82,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")), content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -100,7 +100,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")), content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemLocationContent( val expected = TimelineItemLocationContent(
@ -116,7 +116,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = LocationMessageType("body", "", null)), content = createMessageContent(type = LocationMessageType("body", "", null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -134,7 +134,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = TextMessageType("body", null)), content = createMessageContent(type = TextMessageType("body", null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -152,7 +152,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = TextMessageType("https://www.example.org", null)), content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) as TimelineItemTextContent ) as TimelineItemTextContent
val expected = TimelineItemTextContent( val expected = TimelineItemTextContent(
@ -200,7 +200,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, expected.toString()) formatted = FormattedBody(MessageFormat.HTML, expected.toString())
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
@ -218,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted") formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemTextContent).formattedBody).isNull() assertThat((result as TimelineItemTextContent).formattedBody).isNull()
@ -229,7 +229,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)), content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
@ -277,7 +277,7 @@ class TimelineItemContentMessageFactoryTest {
), ),
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVideoContent( val expected = TimelineItemVideoContent(
@ -303,7 +303,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = AudioMessageType("body", MediaSource("url"), null)), content = createMessageContent(type = AudioMessageType("body", MediaSource("url"), null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
@ -332,7 +332,7 @@ class TimelineItemContentMessageFactoryTest {
) )
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
@ -351,7 +351,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)), content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
@ -384,7 +384,7 @@ class TimelineItemContentMessageFactoryTest {
), ),
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemVoiceContent( val expected = TimelineItemVoiceContent(
@ -409,7 +409,7 @@ class TimelineItemContentMessageFactoryTest {
) )
val result = sut.create( val result = sut.create(
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)), content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemAudioContent( val expected = TimelineItemAudioContent(
@ -428,7 +428,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)), content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemImageContent( val expected = TimelineItemImageContent(
@ -499,7 +499,7 @@ class TimelineItemContentMessageFactoryTest {
) )
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemImageContent( val expected = TimelineItemImageContent(
@ -524,7 +524,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = FileMessageType("body", MediaSource("url"), null)), content = createMessageContent(type = FileMessageType("body", MediaSource("url"), null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemFileContent( val expected = TimelineItemFileContent(
@ -559,7 +559,7 @@ class TimelineItemContentMessageFactoryTest {
) )
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemFileContent( val expected = TimelineItemFileContent(
@ -578,7 +578,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = NoticeMessageType("body", null)), content = createMessageContent(type = NoticeMessageType("body", null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemNoticeContent( val expected = TimelineItemNoticeContent(
@ -601,7 +601,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted") formatted = FormattedBody(MessageFormat.HTML, "formatted")
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted") assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
@ -612,7 +612,7 @@ class TimelineItemContentMessageFactoryTest {
val sut = createTimelineItemContentMessageFactory() val sut = createTimelineItemContentMessageFactory()
val result = sut.create( val result = sut.create(
content = createMessageContent(type = EmoteMessageType("body", null)), content = createMessageContent(type = EmoteMessageType("body", null)),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
val expected = TimelineItemEmoteContent( val expected = TimelineItemEmoteContent(
@ -635,7 +635,7 @@ class TimelineItemContentMessageFactoryTest {
formatted = FormattedBody(MessageFormat.HTML, "formatted") formatted = FormattedBody(MessageFormat.HTML, "formatted")
) )
), ),
senderDisplayName = "Bob", senderDisambiguatedDisplayName = "Bob",
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
) )
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted")) 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.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo 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.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.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
@ -39,7 +40,7 @@ class TimelineItemGrouperTest {
id = "0", id = "0",
senderId = A_USER_ID, senderId = A_USER_ID,
senderAvatar = anAvatarData(), senderAvatar = anAvatarData(),
senderDisplayName = "", senderProfile = aProfileTimelineDetailsReady(displayName = ""),
content = TimelineItemStateEventContent(body = "a state event"), content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), 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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_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.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import org.junit.Test import org.junit.Test
class InReplyToDetailTest { class InReplyToDetailTest {
@ -54,8 +55,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready( val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
senderId = A_USER_ID, senderId = A_USER_ID,
senderDisplayName = "senderDisplayName", senderProfile = aProfileTimelineDetails(),
senderAvatarUrl = "senderAvatarUrl",
content = RoomMembershipContent( content = RoomMembershipContent(
userId = A_USER_ID, userId = A_USER_ID,
change = MembershipChange.INVITED, change = MembershipChange.INVITED,
@ -73,8 +73,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready( val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
senderId = A_USER_ID, senderId = A_USER_ID,
senderDisplayName = "senderDisplayName", senderProfile = aProfileTimelineDetails(),
senderAvatarUrl = "senderAvatarUrl",
content = MessageContent( content = MessageContent(
body = "**Hello!**", body = "**Hello!**",
inReplyTo = null, inReplyTo = null,
@ -101,8 +100,7 @@ class InReplyToDetailTest {
val inReplyTo = InReplyTo.Ready( val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
senderId = A_USER_ID, senderId = A_USER_ID,
senderDisplayName = "senderDisplayName", senderProfile = aProfileTimelineDetails(),
senderAvatarUrl = "senderAvatarUrl",
content = MessageContent( content = MessageContent(
body = "**Hello!**", body = "**Hello!**",
inReplyTo = null, 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.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState 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.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.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent 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.media.aMediaSource
import io.element.android.libraries.matrix.test.timeline.aMessageContent 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.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.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@ -430,15 +432,13 @@ class InReplyToMetadataKtTest {
fun anInReplyToDetails( fun anInReplyToDetails(
eventId: EventId = AN_EVENT_ID, eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID, senderId: UserId = A_USER_ID,
senderDisplayName: String? = "senderDisplayName", senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
senderAvatarUrl: String? = "senderAvatarUrl",
eventContent: EventContent? = aMessageContent(), eventContent: EventContent? = aMessageContent(),
textContent: String? = "textContent", textContent: String? = "textContent",
) = InReplyToDetails( ) = InReplyToDetails(
eventId = eventId, eventId = eventId,
senderId = senderId, senderId = senderId,
senderDisplayName = senderDisplayName, senderProfile = senderProfile,
senderAvatarUrl = senderAvatarUrl,
eventContent = eventContent, eventContent = eventContent,
textContent = textContent, 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 @PreviewsDayNight
@Composable @Composable
internal fun OnBoardingScreenPreview( internal fun OnBoardingViewPreview(
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState @PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview { ) = ElementPreview {
OnBoardingView( OnBoardingView(

View file

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

View file

@ -241,7 +241,7 @@ private fun CreatorView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PollContentUndisclosedPreview() = ElementPreview { internal fun PollContentViewUndisclosedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
@ -258,7 +258,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PollContentDisclosedPreview() = ElementPreview { internal fun PollContentViewDisclosedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
@ -275,7 +275,7 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PollContentEndedPreview() = ElementPreview { internal fun PollContentViewEndedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
@ -292,7 +292,7 @@ internal fun PollContentEndedPreview() = ElementPreview {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PollContentCreatorEditablePreview() = ElementPreview { internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
@ -309,7 +309,7 @@ internal fun PollContentCreatorEditablePreview() = ElementPreview {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PollContentCreatorPreview() = ElementPreview { internal fun PollContentViewCreatorPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
@ -326,7 +326,7 @@ internal fun PollContentCreatorPreview() = ElementPreview {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun PollContentCreatorEndedPreview() = ElementPreview { internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", 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> override val values: Sequence<DeveloperSettingsState>
get() = sequenceOf( get() = sequenceOf(
aDeveloperSettingsState(), aDeveloperSettingsState(),
aDeveloperSettingsState().copy(clearCacheAction = AsyncData.Loading()), aDeveloperSettingsState(
aDeveloperSettingsState().copy( clearCacheAction = AsyncData.Loading()
customElementCallBaseUrlState = CustomElementCallBaseUrlState( ),
aDeveloperSettingsState(
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
baseUrl = "https://call.element.ahoy", 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(), features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(), rageshakeState = aRageshakePreferencesState(),
cacheSize = AsyncData.Success("1.2 MB"), cacheSize = AsyncData.Success("1.2 MB"),
clearCacheAction = AsyncData.Uninitialized, clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(baseUrl = null, defaultUrl = "https://call.element.io", validator = { true }), customElementCallBaseUrlState = customElementCallBaseUrlState,
eventSink = {} 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( ) = EditDefaultNotificationSettingState(
isOneToOne = isOneToOne, isOneToOne = isOneToOne,
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
roomsWithUserDefinedMode = persistentListOf(aRoomSummary()), roomsWithUserDefinedMode = persistentListOf(
aRoomSummary("Room"),
aRoomSummary(null),
),
changeNotificationSettingAction = changeNotificationSettingAction, changeNotificationSettingAction = changeNotificationSettingAction,
displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer,
eventSink = {} eventSink = {}
) )
private fun aRoomSummary() = RoomSummary.Filled( private fun aRoomSummary(
name: String?,
) = RoomSummary.Filled(
aRoomSummaryDetails( aRoomSummaryDetails(
roomId = RoomId("!roomId:domain"), roomId = RoomId("!roomId:domain"),
name = "Room", name = name,
avatarUrl = null, avatarUrl = null,
isDirect = false, isDirect = false,
lastMessage = null, lastMessage = null,

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -100,7 +101,11 @@ fun EditDefaultNotificationSettingView(
) )
ListItem( ListItem(
headlineContent = { 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 = { supportingContent = {
Text(text = subtitle) 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.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.rageshake.api.crash.CrashDataStore 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.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder 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.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -40,6 +40,7 @@ class BugReportPresenter @Inject constructor(
private val bugReporter: BugReporter, private val bugReporter: BugReporter,
private val crashDataStore: CrashDataStore, private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder, private val screenshotHolder: ScreenshotHolder,
private val logFilesRemover: LogFilesRemover,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
) : Presenter<BugReportState> { ) : Presenter<BugReportState> {
private class BugReporterUploadListener( private class BugReporterUploadListener(
@ -150,6 +151,6 @@ class BugReportPresenter @Inject constructor(
private fun CoroutineScope.resetAll() = launch { private fun CoroutineScope.resetAll() = launch {
screenshotHolder.reset() screenshotHolder.reset()
crashDataStore.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) { override fun setCurrentTracingFilter(tracingFilter: String) {
currentTracingFilter = tracingFilter currentTracingFilter = tracingFilter
} }
@ -374,7 +380,6 @@ class DefaultBugReporter @Inject constructor(
/** /**
* Delete all the log files except the most recent one. * Delete all the log files except the most recent one.
*
*/ */
private fun List<File>.deleteAllExceptMostRecent() { private fun List<File>.deleteAllExceptMostRecent() {
if (size > 1) { if (size > 1) {

View file

@ -21,15 +21,18 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDataStore 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.BugReporter
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder 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.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore 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.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -117,9 +120,11 @@ class BugReportPresenterTest {
@Test @Test
fun `present - reset all`() = runTest { fun `present - reset all`() = runTest {
val logFilesRemoverLambda = lambdaRecorder { -> }
val presenter = createPresenter( val presenter = createPresenter(
crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
logFilesRemover = FakeLogFilesRemover(logFilesRemoverLambda),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -131,6 +136,7 @@ class BugReportPresenterTest {
initialState.eventSink.invoke(BugReportEvents.ResetAll) initialState.eventSink.invoke(BugReportEvents.ResetAll)
val resetState = awaitItem() val resetState = awaitItem()
assertThat(resetState.hasCrashLogs).isFalse() assertThat(resetState.hasCrashLogs).isFalse()
logFilesRemoverLambda.assertions().isCalledExactly(1)
// TODO Make it live assertThat(resetState.screenshotUri).isNull() // TODO Make it live assertThat(resetState.screenshotUri).isNull()
} }
} }
@ -239,10 +245,12 @@ class BugReportPresenterTest {
bugReporter: BugReporter = FakeBugReporter(), bugReporter: BugReporter = FakeBugReporter(),
crashDataStore: CrashDataStore = FakeCrashDataStore(), crashDataStore: CrashDataStore = FakeCrashDataStore(),
screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(), screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(),
logFilesRemover: LogFilesRemover = FakeLogFilesRemover(lambdaRecorder(ensureNeverCalled = true) { -> }),
) = BugReportPresenter( ) = BugReportPresenter(
bugReporter, bugReporter = bugReporter,
crashDataStore, crashDataStore = crashDataStore,
screenshotHolder, screenshotHolder = screenshotHolder,
this, 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 { dependencies {
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(libs.coroutines.core) 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 { android {
namespace = "io.element.android.features.roomaliasresolver.impl" namespace = "io.element.android.features.roomaliasresolver.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -44,10 +49,13 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

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

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