Merge branch 'develop' into feature/fga/permalink_timeline
This commit is contained in:
@@ -219,6 +219,7 @@ dependencies {
|
||||
allServicesImpl()
|
||||
allFeaturesImpl(rootDir, logger)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.migration.api)
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.appnav)
|
||||
implementation(projects.appconfig)
|
||||
|
||||
@@ -86,6 +86,7 @@ class MainActivity : NodeActivity() {
|
||||
appBindings.preferencesStore().getThemeFlow().mapToTheme()
|
||||
}
|
||||
.collectAsState(initial = Theme.System)
|
||||
val migrationState = appBindings.migrationEntryPoint().present()
|
||||
ElementTheme(
|
||||
darkTheme = theme.isDark()
|
||||
) {
|
||||
@@ -98,19 +99,12 @@ class MainActivity : NodeActivity() {
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||
MainNode(
|
||||
it,
|
||||
plugins = listOf(
|
||||
object : NodeReadyObserver<MainNode> {
|
||||
override fun init(node: MainNode) {
|
||||
Timber.tag(loggerTag.value).w("onMainNodeInit")
|
||||
mainNode = node
|
||||
mainNode.handleIntent(intent)
|
||||
}
|
||||
}
|
||||
),
|
||||
context = applicationContext
|
||||
if (migrationState.migrationAction.isSuccess()) {
|
||||
MainNodeHost()
|
||||
} else {
|
||||
appBindings.migrationEntryPoint().Render(
|
||||
state = migrationState,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,6 +112,25 @@ class MainActivity : NodeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainNodeHost() {
|
||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||
MainNode(
|
||||
it,
|
||||
plugins = listOf(
|
||||
object : NodeReadyObserver<MainNode> {
|
||||
override fun init(node: MainNode) {
|
||||
Timber.tag(loggerTag.value).w("onMainNodeInit")
|
||||
mainNode = node
|
||||
mainNode.handleIntent(intent)
|
||||
}
|
||||
}
|
||||
),
|
||||
context = applicationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when:
|
||||
* - the launcher icon is clicked (if the app is already running);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.x.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.features.api.MigrationEntryPoint
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
@@ -35,4 +36,6 @@ interface AppBindings {
|
||||
fun lockScreenService(): LockScreenService
|
||||
|
||||
fun preferencesStore(): AppPreferencesStore
|
||||
|
||||
fun migrationEntryPoint(): MigrationEntryPoint
|
||||
}
|
||||
|
||||
@@ -17,10 +17,9 @@
|
||||
package io.element.android.appnav.room
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
@@ -36,7 +35,11 @@ import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
|
||||
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||
import io.element.android.appnav.room.joined.LoadingRoomNodeView
|
||||
import io.element.android.appnav.room.joined.LoadingRoomState
|
||||
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
@@ -44,7 +47,6 @@ import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
@@ -69,6 +71,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
private val joinRoomEntryPoint: JoinRoomEntryPoint,
|
||||
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
) : BaseFlowNode<RoomFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Loading,
|
||||
@@ -125,7 +128,15 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
|
||||
val info = roomInfo.getOrNull()
|
||||
if (info?.currentUserMembership == CurrentUserMembership.JOINED) {
|
||||
backstack.newRoot(NavTarget.JoinedRoom(roomId))
|
||||
if (info.isSpace) {
|
||||
// It should not happen, but probably due to an issue in the sliding sync,
|
||||
// we can have a space here in case the space has just been joined.
|
||||
// So navigate to the JoinRoom target for now, which will
|
||||
// handle the space not supported screen
|
||||
backstack.newRoot(NavTarget.JoinRoom(roomId))
|
||||
} else {
|
||||
backstack.newRoot(NavTarget.JoinedRoom(roomId))
|
||||
}
|
||||
} else {
|
||||
backstack.newRoot(NavTarget.JoinRoom(roomId))
|
||||
}
|
||||
@@ -175,10 +186,14 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadingNode(buildContext: BuildContext) = node(buildContext) {
|
||||
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
||||
val networkStatus by networkMonitor.connectivity.collectAsState()
|
||||
LoadingRoomNodeView(
|
||||
state = LoadingRoomState.Loading,
|
||||
hasNetworkConnection = networkStatus == NetworkStatus.Online,
|
||||
onBackClicked = { navigateUp() },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -68,7 +68,7 @@ fun RootView(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RootPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
|
||||
internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
|
||||
RootView(
|
||||
state = rootState,
|
||||
onOpenBugReport = {},
|
||||
|
||||
1
changelog.d/2718.bugfix
Normal file
1
changelog.d/2718.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Fix session verification being asked again for already verified users.
|
||||
1
changelog.d/2740.bugfix
Normal file
1
changelog.d/2740.bugfix
Normal 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
1
changelog.d/2749.misc
Normal file
@@ -0,0 +1 @@
|
||||
Migrate application data.
|
||||
@@ -43,8 +43,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.PageTitle
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies {
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.analytics.api)
|
||||
@@ -60,6 +61,7 @@ dependencies {
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.libraries.permissions.impl)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveToNextStep() {
|
||||
private fun moveToNextStep() = lifecycleScope.launch {
|
||||
when (ftueState.getNextStep()) {
|
||||
FtueStep.SessionVerification -> {
|
||||
backstack.newRoot(NavTarget.SessionVerification)
|
||||
|
||||
@@ -41,8 +41,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.background.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.OnboardingBackground
|
||||
import io.element.android.libraries.designsystem.components.PageTitle
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
|
||||
@@ -58,9 +58,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object CreateNewRecoveryKey : NavTarget
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
@@ -68,10 +65,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
|
||||
override fun onCreateNewRecoveryKey() {
|
||||
backstack.push(NavTarget.CreateNewRecoveryKey)
|
||||
}
|
||||
|
||||
override fun onDone() {
|
||||
lifecycleScope.launch {
|
||||
// Move to the completed state view in the verification flow
|
||||
@@ -89,10 +82,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
backstack.push(NavTarget.EnterRecoveryKey)
|
||||
}
|
||||
|
||||
override fun onCreateNewRecoveryKey() {
|
||||
backstack.push(NavTarget.CreateNewRecoveryKey)
|
||||
}
|
||||
|
||||
override fun onDone() {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
@@ -105,12 +94,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
||||
.callback(secureBackupEntryPointCallback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.CreateNewRecoveryKey -> {
|
||||
secureBackupEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
|
||||
.callback(secureBackupEntryPointCallback)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,18 +23,26 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultFtueService @Inject constructor(
|
||||
@@ -44,6 +52,7 @@ class DefaultFtueService @Inject constructor(
|
||||
private val permissionStateProvider: PermissionStateProvider,
|
||||
private val lockScreenService: LockScreenService,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
) : FtueService {
|
||||
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
|
||||
|
||||
@@ -55,7 +64,7 @@ class DefaultFtueService @Inject constructor(
|
||||
}
|
||||
|
||||
init {
|
||||
sessionVerificationService.needsVerificationFlow
|
||||
sessionVerificationService.sessionVerifiedStatus
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
@@ -64,7 +73,7 @@ class DefaultFtueService @Inject constructor(
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (isSessionNotVerified()) {
|
||||
FtueStep.SessionVerification
|
||||
@@ -89,8 +98,8 @@ class DefaultFtueService @Inject constructor(
|
||||
FtueStep.AnalyticsOptIn -> null
|
||||
}
|
||||
|
||||
private fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf(
|
||||
private suspend fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf<suspend () -> Boolean>(
|
||||
{ isSessionNotVerified() },
|
||||
{ shouldAskNotificationPermissions() },
|
||||
{ needsAnalyticsOptIn() },
|
||||
@@ -98,16 +107,28 @@ class DefaultFtueService @Inject constructor(
|
||||
).any { it() }
|
||||
}
|
||||
|
||||
private fun isSessionNotVerified(): Boolean {
|
||||
return sessionVerificationService.needsVerificationFlow.value
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun isSessionNotVerified(): Boolean {
|
||||
// Wait for the first known (or ready) verification status
|
||||
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
|
||||
.filter { it != SessionVerifiedStatus.Unknown }
|
||||
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
|
||||
.timeout(5.seconds)
|
||||
.catch {
|
||||
Timber.e(it, "Failed to get session verification status, assume it's not verified")
|
||||
emit(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
.first()
|
||||
val skipVerification = suspend { sessionPreferencesStore.isSessionVerificationSkipped().first() }
|
||||
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !skipVerification()
|
||||
}
|
||||
|
||||
private fun needsAnalyticsOptIn(): Boolean {
|
||||
private suspend fun needsAnalyticsOptIn(): Boolean {
|
||||
// We need this function to not be suspend, so we need to load the value through runBlocking
|
||||
return runBlocking { analyticsService.didAskUserConsent().first().not() }
|
||||
return analyticsService.didAskUserConsent().first().not()
|
||||
}
|
||||
|
||||
private fun shouldAskNotificationPermissions(): Boolean {
|
||||
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
||||
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
val permission = Manifest.permission.POST_NOTIFICATIONS
|
||||
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
|
||||
@@ -118,14 +139,12 @@ class DefaultFtueService @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldDisplayLockscreenSetup(): Boolean {
|
||||
return runBlocking {
|
||||
lockScreenService.isSetupRequired().first()
|
||||
}
|
||||
private suspend fun shouldDisplayLockscreenSetup(): Boolean {
|
||||
return lockScreenService.isSetupRequired().first()
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun updateState() {
|
||||
internal suspend fun updateState() {
|
||||
state.value = when {
|
||||
isAnyStepIncomplete() -> FtueState.Incomplete
|
||||
else -> FtueState.Complete
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Няправільны QR-код"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Перайсці ў налады камеры"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць Element выкарыстоўваць камеру вашай прылады."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Дазвольце доступ да камеры для сканавання QR-кода"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Сканаваць QR-код"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Пачаць спачатку"</string>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Špatný QR kód"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Přejděte na nastavení fotoaparátu"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci Element udělit povolení k použití kamery vašeho zařízení."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povolte přístup k fotoaparátu a naskenujte QR kód"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Naskenujte QR kód"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Začít znovu"</string>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Wenn das nicht funktioniert, melde dich manuell an"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_title">"Die Verbindung ist nicht sicher"</string>
|
||||
<string name="screen_qr_code_login_device_code_subtitle">"Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."</string>
|
||||
<string name="screen_qr_code_login_device_code_title">"Nummer auf deinem Gerät eingeben"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"%1$s auf einem Desktop-Gerät öffnen"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Klick auf deinen Avatar"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string>
|
||||
@@ -20,11 +22,14 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Falscher QR-Code"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Gehe zu den Kameraeinstellungen"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst Element die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"QR-Code scannen"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Neu beginnen"</string>
|
||||
<string name="screen_qr_code_login_unknown_error_description">"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."</string>
|
||||
<string name="screen_qr_code_login_verify_code_loading">"Warten auf dein anderes Gerät"</string>
|
||||
<string name="screen_qr_code_login_verify_code_subtitle">"Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen."</string>
|
||||
<string name="screen_qr_code_login_verify_code_title">"Dein Verifizierungscode"</string>
|
||||
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."</string>
|
||||
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
|
||||
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit."</string>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ha ez nem működik, jelentkezzen be kézileg"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string>
|
||||
<string name="screen_qr_code_login_device_code_subtitle">"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet."</string>
|
||||
<string name="screen_qr_code_login_device_code_title">"Adja meg a számot az eszközén"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Nyissa meg az %1$set egy asztali eszközön"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Kattintson a profilképére"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string>
|
||||
@@ -20,11 +22,14 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Hibás QR-kód"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Ugrás a kamerabeállításokhoz"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az Element használhassa az eszköz kameráját."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Engedélyezze a kamera elérését a QR-kód beolvasásához"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Olvassa be a QR-kódot"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Újrakezdés"</string>
|
||||
<string name="screen_qr_code_login_unknown_error_description">"Váratlan hiba történt. Próbálja meg újra."</string>
|
||||
<string name="screen_qr_code_login_verify_code_loading">"Várakozás a másik eszközre"</string>
|
||||
<string name="screen_qr_code_login_verify_code_subtitle">"A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez."</string>
|
||||
<string name="screen_qr_code_login_verify_code_title">"Az Ön ellenőrzőkódja"</string>
|
||||
<string name="screen_welcome_bullet_1">"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."</string>
|
||||
<string name="screen_welcome_bullet_2">"A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben."</string>
|
||||
<string name="screen_welcome_bullet_3">"Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon."</string>
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
<string name="screen_notification_optin_subtitle">"Anda dapat mengubah pengaturan Anda nanti."</string>
|
||||
<string name="screen_notification_optin_title">"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"</string>
|
||||
<string name="screen_qr_code_login_connecting_subtitle">"Membuat koneksi"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Jika tidak berhasil, masuk secara manual"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_title">"Koneksi tidak aman"</string>
|
||||
<string name="screen_qr_code_login_device_code_subtitle">"Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini."</string>
|
||||
<string name="screen_qr_code_login_device_code_title">"Masukkan nomor di perangkat Anda"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Buka %1$s di perangkat desktop"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Klik pada avatar Anda"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string>
|
||||
@@ -13,7 +21,15 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Kode QR salah"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Pergi ke pengaturan kamera"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Izinkan akses kamera untuk memindai kode QR"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Pindai kode QR"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Mulai dari awal"</string>
|
||||
<string name="screen_qr_code_login_unknown_error_description">"Terjadi kesalahan tak terduga. Silakan coba lagi."</string>
|
||||
<string name="screen_qr_code_login_verify_code_loading">"Menunggu perangkat Anda yang lain"</string>
|
||||
<string name="screen_qr_code_login_verify_code_subtitle">"Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk."</string>
|
||||
<string name="screen_qr_code_login_verify_code_title">"Kode verifikasi Anda"</string>
|
||||
<string name="screen_welcome_bullet_1">"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."</string>
|
||||
<string name="screen_welcome_bullet_2">"Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini."</string>
|
||||
<string name="screen_welcome_bullet_3">"Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan."</string>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string>
|
||||
<string name="screen_qr_code_login_device_code_subtitle">"Вам будет предложено ввести две цифры, показанные ниже."</string>
|
||||
<string name="screen_qr_code_login_device_code_title">"Введите номер на своем устройстве"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на настольном устройстве"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
|
||||
@@ -20,11 +22,14 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неверный QR-код"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Перейдите в настройки камеры"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить Element использовать камеру вашего устройства."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Разрешите доступ к камере для сканирования QR-кода"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Сканировать QR-код"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Начать заново"</string>
|
||||
<string name="screen_qr_code_login_unknown_error_description">"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."</string>
|
||||
<string name="screen_qr_code_login_verify_code_loading">"В ожидании другого устройства"</string>
|
||||
<string name="screen_qr_code_login_verify_code_subtitle">"Поставщик учетной записи может запросить следующий код для подтверждения входа."</string>
|
||||
<string name="screen_qr_code_login_verify_code_title">"Ваш код подтверждения"</string>
|
||||
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
|
||||
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
|
||||
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Nesprávny QR kód"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Prejsť na nastavenia fotoaparátu"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii Element používať fotoaparát vášho zariadenia."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povoľte prístup k fotoaparátu na naskenovanie QR kódu"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Naskenovať QR kód"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Začať odznova"</string>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"If that doesn’t work, sign in manually"</string>
|
||||
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connection not secure"</string>
|
||||
<string name="screen_qr_code_login_device_code_subtitle">"You’ll be asked to enter the two digits shown below."</string>
|
||||
<string name="screen_qr_code_login_device_code_title">"Enter number on your device"</string>
|
||||
<string name="screen_qr_code_login_device_code_subtitle">"You’ll be asked to enter the two digits shown on this device."</string>
|
||||
<string name="screen_qr_code_login_device_code_title">"Enter the number below on your other device"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_1">"Open %1$s on a desktop device"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
|
||||
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
|
||||
@@ -22,7 +22,7 @@
|
||||
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string>
|
||||
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Wrong QR code"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_button">"Go to camera settings"</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for Element to use your device’s 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 device’s camera in order to continue."</string>
|
||||
<string name="screen_qr_code_login_no_camera_permission_state_title">"Allow camera access to scan the QR code"</string>
|
||||
<string name="screen_qr_code_login_scanning_state_title">"Scan the QR code"</string>
|
||||
<string name="screen_qr_code_login_start_over_button">"Start over"</string>
|
||||
|
||||
@@ -24,6 +24,7 @@ import io.element.android.features.ftue.impl.state.DefaultFtueService
|
||||
import io.element.android.features.ftue.impl.state.FtueStep
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.lockscreen.test.FakeLockScreenService
|
||||
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
|
||||
@@ -90,7 +91,6 @@ class DefaultFtueServiceTests {
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
givenNeedsVerification(true)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
@@ -108,7 +108,7 @@ class DefaultFtueServiceTests {
|
||||
|
||||
// Session verification
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenNeedsVerification(false)
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
@@ -200,6 +200,7 @@ class DefaultFtueServiceTests {
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
|
||||
lockScreenService: LockScreenService = FakeLockScreenService(),
|
||||
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||
// First version where notification permission is required
|
||||
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
|
||||
) = DefaultFtueService(
|
||||
@@ -209,5 +210,6 @@ class DefaultFtueServiceTests {
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,12 +102,14 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||
|
||||
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncAction<RoomId>>) = launch {
|
||||
acceptedAction.runUpdatingState {
|
||||
client.joinRoom(roomId).onSuccess {
|
||||
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
|
||||
client.getRoom(roomId)?.use { room ->
|
||||
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
|
||||
client.joinRoom(roomId)
|
||||
.onSuccess {
|
||||
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
|
||||
client.getRoom(roomId)?.use { room ->
|
||||
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { roomId }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||
@Test
|
||||
fun `present - accepting invite error flow`() = runTest {
|
||||
val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
|
||||
Result.failure<RoomId>(RuntimeException("Failed to join room $roomId"))
|
||||
Result.failure<Unit>(RuntimeException("Failed to join room $roomId"))
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
joinRoomLambda = joinRoomFailure
|
||||
@@ -197,8 +197,8 @@ class AcceptDeclineInvitePresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - accepting invite success flow`() = runTest {
|
||||
val joinRoomSuccess = lambdaRecorder { roomId: RoomId ->
|
||||
Result.success(roomId)
|
||||
val joinRoomSuccess = lambdaRecorder { _: RoomId ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
joinRoomLambda = joinRoomSuccess
|
||||
|
||||
@@ -23,6 +23,11 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.joinroom.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
@@ -46,10 +51,13 @@ dependencies {
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ package io.element.android.features.joinroom.impl
|
||||
sealed interface JoinRoomEvents {
|
||||
data object RetryFetchingContent : JoinRoomEvents
|
||||
data object JoinRoom : JoinRoomEvents
|
||||
data object KnockRoom : JoinRoomEvents
|
||||
data object ClearError : JoinRoomEvents
|
||||
data object AcceptInvite : JoinRoomEvents
|
||||
data object DeclineInvite : JoinRoomEvents
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class JoinRoomNode @AssistedInject constructor(
|
||||
JoinRoomView(
|
||||
state = state,
|
||||
onBackPressed = ::navigateUp,
|
||||
onKnockSuccess = ::navigateUp,
|
||||
modifier = modifier
|
||||
)
|
||||
acceptDeclineInviteView.Render(
|
||||
|
||||
@@ -18,26 +18,37 @@ package io.element.android.features.joinroom.impl
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
import io.element.android.features.joinroom.impl.di.KnockRoom
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomType
|
||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
|
||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Optional
|
||||
|
||||
class JoinRoomPresenter @AssistedInject constructor(
|
||||
@@ -45,7 +56,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
@Assisted private val roomIdOrAlias: RoomIdOrAlias,
|
||||
@Assisted private val roomDescription: Optional<RoomDescription>,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val knockRoom: KnockRoom,
|
||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<JoinRoomState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
@@ -57,8 +70,10 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
|
||||
@Composable
|
||||
override fun present(): JoinRoomState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var retryCount by remember { mutableIntStateOf(0) }
|
||||
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
|
||||
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val contentState by produceState<ContentState>(
|
||||
initialValue = ContentState.Loading(roomIdOrAlias),
|
||||
key1 = roomInfo,
|
||||
@@ -75,7 +90,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
value = ContentState.Loading(roomIdOrAlias)
|
||||
val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias())
|
||||
value = result.fold(
|
||||
onSuccess = { it.toContentState() },
|
||||
onSuccess = { roomPreview ->
|
||||
roomPreview.toContentState()
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
if (throwable.message?.contains("403") == true) {
|
||||
ContentState.UnknownRoom(roomIdOrAlias)
|
||||
@@ -98,6 +115,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
AcceptDeclineInviteEvents.AcceptInvite(inviteData)
|
||||
)
|
||||
}
|
||||
JoinRoomEvents.KnockRoom -> {
|
||||
coroutineScope.knockRoom(roomId, knockAction)
|
||||
}
|
||||
JoinRoomEvents.DeclineInvite -> {
|
||||
val inviteData = contentState.toInviteData() ?: return
|
||||
acceptDeclineInviteState.eventSink(
|
||||
@@ -107,15 +127,26 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
JoinRoomEvents.RetryFetchingContent -> {
|
||||
retryCount++
|
||||
}
|
||||
JoinRoomEvents.ClearError -> {
|
||||
knockAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JoinRoomState(
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
knockAction = knockAction.value,
|
||||
applicationName = buildMeta.applicationName,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.knockRoom(roomId: RoomId, knockAction: MutableState<AsyncAction<Unit>>) = launch {
|
||||
knockAction.runUpdatingState {
|
||||
knockRoom(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomPreview.toContentState(): ContentState {
|
||||
@@ -126,9 +157,11 @@ private fun RoomPreview.toContentState(): ContentState {
|
||||
alias = canonicalAlias,
|
||||
numberOfMembers = numberOfJoinedMembers,
|
||||
isDirect = false,
|
||||
roomType = roomType,
|
||||
roomAvatarUrl = avatarUrl,
|
||||
joinAuthorisationStatus = when {
|
||||
isInvited -> JoinAuthorisationStatus.IsInvited
|
||||
// Note when isInvited, roomInfo will be used, so if this happen, it will be temporary.
|
||||
isInvited -> JoinAuthorisationStatus.IsInvited(null)
|
||||
canKnock -> JoinAuthorisationStatus.CanKnock
|
||||
isPublic -> JoinAuthorisationStatus.CanJoin
|
||||
else -> JoinAuthorisationStatus.Unknown
|
||||
@@ -145,6 +178,7 @@ internal fun RoomDescription.toContentState(): ContentState {
|
||||
alias = alias,
|
||||
numberOfMembers = numberOfMembers,
|
||||
isDirect = false,
|
||||
roomType = RoomType.Room,
|
||||
roomAvatarUrl = avatarUrl,
|
||||
joinAuthorisationStatus = when (joinRule) {
|
||||
RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock
|
||||
@@ -163,9 +197,12 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
|
||||
alias = canonicalAlias,
|
||||
numberOfMembers = activeMembersCount,
|
||||
isDirect = isDirect,
|
||||
roomType = if (isSpace) RoomType.Space else RoomType.Room,
|
||||
roomAvatarUrl = avatarUrl,
|
||||
joinAuthorisationStatus = when {
|
||||
currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited
|
||||
currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
|
||||
inviteSender = inviter?.toInviteSender()
|
||||
)
|
||||
isPublic -> JoinAuthorisationStatus.CanJoin
|
||||
else -> JoinAuthorisationStatus.Unknown
|
||||
}
|
||||
@@ -177,7 +214,8 @@ internal fun ContentState.toInviteData(): InviteData? {
|
||||
return when (this) {
|
||||
is ContentState.Loaded -> InviteData(
|
||||
roomId = roomId,
|
||||
roomName = computedTitle,
|
||||
// Note: name should not be null at this point, but use Id just in case...
|
||||
roomName = name ?: roomId.value,
|
||||
isDirect = isDirect
|
||||
)
|
||||
else -> null
|
||||
|
||||
@@ -18,16 +18,21 @@ package io.element.android.features.joinroom.impl
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.RoomType
|
||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||
|
||||
@Immutable
|
||||
data class JoinRoomState(
|
||||
val contentState: ContentState,
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
val knockAction: AsyncAction<Unit>,
|
||||
val applicationName: String,
|
||||
val eventSink: (JoinRoomEvents) -> Unit
|
||||
) {
|
||||
val joinAuthorisationStatus = when (contentState) {
|
||||
@@ -47,17 +52,10 @@ sealed interface ContentState {
|
||||
val alias: RoomAlias?,
|
||||
val numberOfMembers: Long?,
|
||||
val isDirect: Boolean,
|
||||
val roomType: RoomType,
|
||||
val roomAvatarUrl: String?,
|
||||
val joinAuthorisationStatus: JoinAuthorisationStatus,
|
||||
) : ContentState {
|
||||
val computedTitle = name ?: roomId.value
|
||||
|
||||
val computedSubtitle = when {
|
||||
alias != null -> alias.value
|
||||
name == null -> ""
|
||||
else -> roomId.value
|
||||
}
|
||||
|
||||
val showMemberCount = numberOfMembers != null
|
||||
|
||||
fun avatarData(size: AvatarSize): AvatarData {
|
||||
@@ -71,9 +69,9 @@ sealed interface ContentState {
|
||||
}
|
||||
}
|
||||
|
||||
enum class JoinAuthorisationStatus {
|
||||
IsInvited,
|
||||
CanKnock,
|
||||
CanJoin,
|
||||
Unknown,
|
||||
sealed interface JoinAuthorisationStatus {
|
||||
data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
|
||||
data object CanKnock : JoinAuthorisationStatus
|
||||
data object CanJoin : JoinAuthorisationStatus
|
||||
data object Unknown : JoinAuthorisationStatus
|
||||
}
|
||||
|
||||
@@ -19,10 +19,16 @@ package io.element.android.features.joinroom.impl
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.RoomType
|
||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||
|
||||
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
|
||||
override val values: Sequence<JoinRoomState>
|
||||
@@ -33,6 +39,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
|
||||
aJoinRoomState(
|
||||
contentState = anUnknownContentState()
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(
|
||||
name = null,
|
||||
alias = null,
|
||||
topic = null,
|
||||
)
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
|
||||
),
|
||||
@@ -48,7 +61,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
|
||||
)
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited)
|
||||
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(
|
||||
numberOfMembers = 123,
|
||||
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(anInviteSender()),
|
||||
)
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aFailureContentState()
|
||||
@@ -56,6 +75,15 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
|
||||
aJoinRoomState(
|
||||
contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias())
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(
|
||||
roomId = RoomId("!aSpaceId:domain"),
|
||||
name = "A space",
|
||||
alias = null,
|
||||
topic = "This is the topic of a space",
|
||||
roomType = RoomType.Space,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,11 +102,12 @@ fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(room
|
||||
|
||||
fun aLoadedContentState(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
name: String = "Element X android",
|
||||
name: String? = "Element X android",
|
||||
alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
|
||||
topic: String? = "Element X is a secure, private and decentralized messenger.",
|
||||
numberOfMembers: Long? = null,
|
||||
isDirect: Boolean = false,
|
||||
roomType: RoomType = RoomType.Room,
|
||||
roomAvatarUrl: String? = null,
|
||||
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
|
||||
) = ContentState.Loaded(
|
||||
@@ -88,6 +117,7 @@ fun aLoadedContentState(
|
||||
topic = topic,
|
||||
numberOfMembers = numberOfMembers,
|
||||
isDirect = isDirect,
|
||||
roomType = roomType,
|
||||
roomAvatarUrl = roomAvatarUrl,
|
||||
joinAuthorisationStatus = joinAuthorisationStatus
|
||||
)
|
||||
@@ -95,12 +125,25 @@ fun aLoadedContentState(
|
||||
fun aJoinRoomState(
|
||||
contentState: ContentState = aLoadedContentState(),
|
||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||
knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (JoinRoomEvents) -> Unit = {}
|
||||
) = JoinRoomState(
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
knockAction = knockAction,
|
||||
applicationName = "AppName",
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
internal fun anInviteSender(
|
||||
userId: UserId = UserId("@bob:domain"),
|
||||
displayName: String = "Bob",
|
||||
avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
|
||||
) = InviteSender(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarData = avatarData,
|
||||
)
|
||||
|
||||
private val A_ROOM_ID = RoomId("!exa:matrix.org")
|
||||
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")
|
||||
|
||||
@@ -17,16 +17,25 @@
|
||||
package io.element.android.features.joinroom.impl
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
|
||||
@@ -35,9 +44,12 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolec
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.background.LightGradientBackground
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.button.SuperButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
@@ -46,40 +58,61 @@ import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.RoomType
|
||||
import io.element.android.libraries.matrix.ui.components.InviteSenderView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun JoinRoomView(
|
||||
state: JoinRoomState,
|
||||
onBackPressed: () -> Unit,
|
||||
onKnockSuccess: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
paddingValues = PaddingValues(16.dp),
|
||||
topBar = {
|
||||
JoinRoomTopBar(onBackClicked = onBackPressed)
|
||||
},
|
||||
content = {
|
||||
JoinRoomContent(contentState = state.contentState)
|
||||
},
|
||||
footer = {
|
||||
JoinRoomFooter(
|
||||
state = state,
|
||||
onAcceptInvite = {
|
||||
state.eventSink(JoinRoomEvents.AcceptInvite)
|
||||
},
|
||||
onDeclineInvite = {
|
||||
state.eventSink(JoinRoomEvents.DeclineInvite)
|
||||
},
|
||||
onJoinRoom = {
|
||||
state.eventSink(JoinRoomEvents.JoinRoom)
|
||||
},
|
||||
onRetry = {
|
||||
state.eventSink(JoinRoomEvents.RetryFetchingContent)
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
LightGradientBackground()
|
||||
HeaderFooterPage(
|
||||
containerColor = Color.Transparent,
|
||||
paddingValues = PaddingValues(16.dp),
|
||||
topBar = {
|
||||
JoinRoomTopBar(onBackClicked = onBackPressed)
|
||||
},
|
||||
content = {
|
||||
JoinRoomContent(
|
||||
contentState = state.contentState,
|
||||
applicationName = state.applicationName,
|
||||
)
|
||||
},
|
||||
footer = {
|
||||
JoinRoomFooter(
|
||||
state = state,
|
||||
onAcceptInvite = {
|
||||
state.eventSink(JoinRoomEvents.AcceptInvite)
|
||||
},
|
||||
onDeclineInvite = {
|
||||
state.eventSink(JoinRoomEvents.DeclineInvite)
|
||||
},
|
||||
onJoinRoom = {
|
||||
state.eventSink(JoinRoomEvents.JoinRoom)
|
||||
},
|
||||
onKnockRoom = {
|
||||
state.eventSink(JoinRoomEvents.KnockRoom)
|
||||
},
|
||||
onRetry = {
|
||||
state.eventSink(JoinRoomEvents.RetryFetchingContent)
|
||||
},
|
||||
onGoBack = onBackPressed,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AsyncActionView(
|
||||
async = state.knockAction,
|
||||
onSuccess = { onKnockSuccess() },
|
||||
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +122,9 @@ private fun JoinRoomFooter(
|
||||
onAcceptInvite: () -> Unit,
|
||||
onDeclineInvite: () -> Unit,
|
||||
onJoinRoom: () -> Unit,
|
||||
onKnockRoom: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onGoBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.contentState is ContentState.Failure) {
|
||||
@@ -97,41 +132,51 @@ private fun JoinRoomFooter(
|
||||
text = stringResource(CommonStrings.action_retry),
|
||||
onClick = onRetry,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
size = ButtonSize.Large,
|
||||
)
|
||||
} else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) {
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_go_back),
|
||||
onClick = onGoBack,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Large,
|
||||
)
|
||||
} else {
|
||||
val joinAuthorisationStatus = state.joinAuthorisationStatus
|
||||
when (joinAuthorisationStatus) {
|
||||
JoinAuthorisationStatus.IsInvited -> {
|
||||
is JoinAuthorisationStatus.IsInvited -> {
|
||||
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
OutlinedButton(
|
||||
text = stringResource(CommonStrings.action_decline),
|
||||
onClick = onDeclineInvite,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
size = ButtonSize.Large,
|
||||
)
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_accept),
|
||||
onClick = onAcceptInvite,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
size = ButtonSize.Large,
|
||||
)
|
||||
}
|
||||
}
|
||||
JoinAuthorisationStatus.CanJoin -> {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_join_room_join_action),
|
||||
SuperButton(
|
||||
onClick = onJoinRoom,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
buttonSize = ButtonSize.Large,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_join_room_join_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
JoinAuthorisationStatus.CanKnock -> {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_join_room_knock_action),
|
||||
onClick = onJoinRoom,
|
||||
onClick = onKnockRoom,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
size = ButtonSize.Large,
|
||||
)
|
||||
}
|
||||
JoinAuthorisationStatus.Unknown -> Unit
|
||||
@@ -142,6 +187,7 @@ private fun JoinRoomFooter(
|
||||
@Composable
|
||||
private fun JoinRoomContent(
|
||||
contentState: ContentState,
|
||||
applicationName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (contentState) {
|
||||
@@ -152,13 +198,48 @@ private fun JoinRoomContent(
|
||||
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
|
||||
},
|
||||
title = {
|
||||
RoomPreviewTitleAtom(contentState.computedTitle)
|
||||
if (contentState.name != null) {
|
||||
RoomPreviewTitleAtom(
|
||||
title = contentState.name,
|
||||
)
|
||||
} else {
|
||||
RoomPreviewTitleAtom(
|
||||
title = stringResource(id = CommonStrings.common_no_room_name),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
},
|
||||
subtitle = {
|
||||
RoomPreviewSubtitleAtom(contentState.computedSubtitle)
|
||||
if (contentState.alias != null) {
|
||||
RoomPreviewSubtitleAtom(contentState.alias.value)
|
||||
}
|
||||
},
|
||||
description = {
|
||||
RoomPreviewDescriptionAtom(contentState.topic ?: "")
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
|
||||
if (inviteSender != null) {
|
||||
InviteSenderView(inviteSender = inviteSender)
|
||||
}
|
||||
RoomPreviewDescriptionAtom(contentState.topic ?: "")
|
||||
if (contentState.roomType == RoomType.Space) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_join_room_space_not_supported_title),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
memberCount = {
|
||||
if (contentState.showMemberCount) {
|
||||
@@ -213,7 +294,7 @@ private fun JoinRoomContent(
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "Failed to get information about the room",
|
||||
text = stringResource(id = CommonStrings.error_unknown),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -241,6 +322,7 @@ private fun JoinRoomTopBar(
|
||||
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
|
||||
JoinRoomView(
|
||||
state = state,
|
||||
onBackPressed = { }
|
||||
onBackPressed = { },
|
||||
onKnockSuccess = { },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -35,7 +36,9 @@ object JoinRoomModule {
|
||||
@Provides
|
||||
fun providesJoinRoomPresenterFactory(
|
||||
client: MatrixClient,
|
||||
knockRoom: KnockRoom,
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
buildMeta: BuildMeta,
|
||||
): JoinRoomPresenter.Factory {
|
||||
return object : JoinRoomPresenter.Factory {
|
||||
override fun create(
|
||||
@@ -48,7 +51,9 @@ object JoinRoomModule {
|
||||
roomIdOrAlias = roomIdOrAlias,
|
||||
roomDescription = roomDescription,
|
||||
matrixClient = client,
|
||||
knockRoom = knockRoom,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
buildMeta = buildMeta,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_join_room_join_action">"Join room"</string>
|
||||
<string name="screen_join_room_knock_action">"Knock to join"</string>
|
||||
<string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string>
|
||||
<string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string>
|
||||
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved."</string>
|
||||
<string name="screen_join_room_subtitle_no_preview">"You must be a member of this room to view the message history."</string>
|
||||
<string name="screen_join_room_title_knock">"Want to join this room?"</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -20,19 +20,27 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.joinroom.impl.di.KnockRoom
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomType
|
||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.ui.model.toInviteSender
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -56,6 +64,7 @@ class JoinRoomPresenterTest {
|
||||
assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
|
||||
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
|
||||
assertThat(state.applicationName).isEqualTo("AppName")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -101,7 +110,31 @@ class JoinRoomPresenterTest {
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited)
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
|
||||
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
|
||||
val expectedInviteSender = inviter.toInviteSender()
|
||||
val roomInfo = aRoomInfo(
|
||||
currentUserMembership = CurrentUserMembership.INVITED,
|
||||
inviter = inviter,
|
||||
)
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { _ ->
|
||||
flowOf(Optional.of(roomInfo))
|
||||
}
|
||||
}
|
||||
val presenter = createJoinRoomPresenter(
|
||||
matrixClient = matrixClient
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(expectedInviteSender))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,6 +275,38 @@ class JoinRoomPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - emit knock room event`() = runTest {
|
||||
val knockRoomSuccess = lambdaRecorder { _: RoomId ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val knockRoomFailure = lambdaRecorder { roomId: RoomId ->
|
||||
Result.failure<Unit>(RuntimeException("Failed to knock room $roomId"))
|
||||
}
|
||||
val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess)
|
||||
val presenter = createJoinRoomPresenter(knockRoom = fakeKnockRoom)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
state.eventSink(JoinRoomEvents.KnockRoom)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit))
|
||||
fakeKnockRoom.lambda = knockRoomFailure
|
||||
state.eventSink(JoinRoomEvents.KnockRoom)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.knockAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
}
|
||||
}
|
||||
assert(knockRoomSuccess)
|
||||
.isCalledOnce()
|
||||
.with(value(A_ROOM_ID))
|
||||
assert(knockRoomFailure)
|
||||
.isCalledOnce()
|
||||
.with(value(A_ROOM_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room is not known RoomPreview is loaded`() = runTest {
|
||||
val client = FakeMatrixClient(
|
||||
@@ -254,7 +319,7 @@ class JoinRoomPresenterTest {
|
||||
topic = "Room topic",
|
||||
avatarUrl = "avatarUrl",
|
||||
numberOfJoinedMembers = 2,
|
||||
roomType = null,
|
||||
roomType = RoomType.Room,
|
||||
isHistoryWorldReadable = false,
|
||||
isJoined = false,
|
||||
isInvited = false,
|
||||
@@ -278,6 +343,7 @@ class JoinRoomPresenterTest {
|
||||
alias = RoomAlias("#alias:matrix.org"),
|
||||
numberOfMembers = 2,
|
||||
isDirect = false,
|
||||
roomType = RoomType.Room,
|
||||
roomAvatarUrl = "avatarUrl",
|
||||
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
|
||||
)
|
||||
@@ -350,6 +416,8 @@ class JoinRoomPresenterTest {
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
roomDescription: Optional<RoomDescription> = Optional.empty(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
knockRoom: KnockRoom = FakeKnockRoom(),
|
||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
|
||||
): JoinRoomPresenter {
|
||||
return JoinRoomPresenter(
|
||||
@@ -357,6 +425,8 @@ class JoinRoomPresenterTest {
|
||||
roomIdOrAlias = roomId.toRoomIdOrAlias(),
|
||||
roomDescription = roomDescription,
|
||||
matrixClient = matrixClient,
|
||||
knockRoom = knockRoom,
|
||||
buildMeta = buildMeta,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ private fun PinUnlockFooter(
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||
internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||
ElementPreview {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
@@ -369,7 +369,7 @@ internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider:
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||
internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||
ElementPreview {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<string name="screen_app_lock_settings_remove_pin">"Ta bort PIN-kod"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin_alert_message">"Är du säker på att du vill ta bort PIN-koden?"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin_alert_title">"Ta bort PIN-koden?"</string>
|
||||
<string name="screen_app_lock_signout_alert_message">"Du måste logga in igen och skapa en ny PIN-kod för att fortsätta"</string>
|
||||
<string name="screen_app_lock_signout_alert_title">"Du blir utloggad"</string>
|
||||
<plurals name="screen_app_lock_subtitle">
|
||||
<item quantity="one">"Du har %1$d försök att låsa upp"</item>
|
||||
|
||||
@@ -48,8 +48,11 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
event = aTimelineItemEvent(
|
||||
content = aTimelineItemImageContent(),
|
||||
displayNameAmbiguous = true,
|
||||
).copy(
|
||||
reactionsState = reactionsState,
|
||||
),
|
||||
displayEmojiReactions = true,
|
||||
actions = aTimelineItemActionList(),
|
||||
@@ -142,6 +145,7 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
}
|
||||
|
||||
fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
|
||||
return persistentListOf(
|
||||
TimelineItemAction.EndPoll,
|
||||
|
||||
@@ -55,6 +55,8 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.sender.SenderName
|
||||
import io.element.android.features.messages.impl.sender.SenderNameMode
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
@@ -268,15 +270,11 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||
icon()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row {
|
||||
if (event.senderDisplayName != null) {
|
||||
Text(
|
||||
text = event.senderDisplayName,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
SenderName(
|
||||
senderId = event.senderId,
|
||||
senderProfile = event.senderProfile,
|
||||
senderNameMode = SenderNameMode.ActionList,
|
||||
)
|
||||
content()
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
@@ -141,7 +141,7 @@ private fun RoomMemberSuggestionItemView(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MentionSuggestionsPickerView_Preview() {
|
||||
internal fun MentionSuggestionsPickerViewPreview() {
|
||||
ElementPreview {
|
||||
val roomMember = RoomMember(
|
||||
userId = UserId("@alice:server.org"),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
@@ -130,6 +131,7 @@ internal fun aTimelineItemEvent(
|
||||
isMine: Boolean = false,
|
||||
isEditable: Boolean = false,
|
||||
senderDisplayName: String = "Sender",
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
sendState: LocalEventSendState? = null,
|
||||
@@ -151,7 +153,10 @@ internal fun aTimelineItemEvent(
|
||||
sentTime = "12:34",
|
||||
isMine = isMine,
|
||||
isEditable = isEditable,
|
||||
senderDisplayName = senderDisplayName,
|
||||
senderProfile = aProfileTimelineDetailsReady(
|
||||
displayName = senderDisplayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
),
|
||||
groupPosition = groupPosition,
|
||||
localSendState = sendState,
|
||||
inReplyTo = inReplyTo,
|
||||
|
||||
@@ -191,7 +191,7 @@ internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionP
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessagesAddReactionButtonPreview() = ElementPreview {
|
||||
internal fun MessagesReactionButtonAddPreview() = ElementPreview {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add),
|
||||
onClick = {},
|
||||
@@ -201,7 +201,7 @@ internal fun MessagesAddReactionButtonPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
|
||||
internal fun MessagesReactionButtonExtraPreview() = ElementPreview {
|
||||
Row {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text("12 more"),
|
||||
|
||||
@@ -69,6 +69,8 @@ import androidx.constraintlayout.compose.ConstrainScope
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.sender.SenderName
|
||||
import io.element.android.features.messages.impl.sender.SenderNameMode
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
@@ -106,6 +108,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -291,7 +295,8 @@ private fun TimelineItemEventRowContent(
|
||||
val avatarStrokeSize = 3.dp
|
||||
if (event.showSenderInformation && !timelineRoomInfo.isDm) {
|
||||
MessageSenderInformation(
|
||||
event.safeSenderName,
|
||||
event.senderId,
|
||||
event.senderProfile,
|
||||
event.senderAvatar,
|
||||
avatarStrokeSize,
|
||||
Modifier
|
||||
@@ -371,7 +376,8 @@ private fun TimelineItemEventRowContent(
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
senderId: UserId,
|
||||
senderProfile: ProfileTimelineDetails,
|
||||
senderAvatar: AvatarData,
|
||||
avatarStrokeSize: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -398,13 +404,10 @@ private fun MessageSenderInformation(
|
||||
Row {
|
||||
Avatar(senderAvatar)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
modifier = Modifier.clipToBounds(),
|
||||
text = sender,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = avatarColors.foreground,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
SenderName(
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -561,10 +564,10 @@ private fun MessageEventBubbleContent(
|
||||
}
|
||||
}
|
||||
val inReplyTo = @Composable { inReplyTo: InReplyToDetails ->
|
||||
val senderName = inReplyTo.senderDisplayName ?: inReplyTo.senderId.value
|
||||
val topPadding = if (showThreadDecoration) 0.dp else 8.dp
|
||||
ReplyToContent(
|
||||
senderName = senderName,
|
||||
senderId = inReplyTo.senderId,
|
||||
senderProfile = inReplyTo.senderProfile,
|
||||
metadata = inReplyTo.metadata(),
|
||||
modifier = Modifier
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
@@ -609,7 +612,8 @@ private fun MessageEventBubbleContent(
|
||||
|
||||
@Composable
|
||||
private fun ReplyToContent(
|
||||
senderName: String,
|
||||
senderId: UserId,
|
||||
senderProfile: ProfileTimelineDetails,
|
||||
metadata: InReplyToMetadata?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -633,18 +637,15 @@ private fun ReplyToContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
|
||||
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
|
||||
Column(verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
SenderName(
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
senderNameMode = SenderNameMode.Reply,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = a11InReplyToText
|
||||
},
|
||||
text = senderName,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
color = ElementTheme.materialColors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
ReplyToContentText(metadata)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,6 @@ internal fun TimelineItemEventRowTimestampPreview(
|
||||
body = str,
|
||||
),
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
senderDisplayName = "A sender",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageConten
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
@@ -58,7 +59,10 @@ internal fun TimelineItemEventRowWithReplyPreview(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
|
||||
internal fun TimelineItemEventRowWithReplyContentToPreview(
|
||||
inReplyToDetails: InReplyToDetails,
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
) {
|
||||
Column {
|
||||
sequenceOf(false, true).forEach {
|
||||
ATimelineItemEventRow(
|
||||
@@ -69,6 +73,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
|
||||
body = "A reply."
|
||||
),
|
||||
inReplyTo = inReplyToDetails,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
)
|
||||
@@ -80,6 +85,7 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InR
|
||||
aspectRatio = 2.5f
|
||||
),
|
||||
inReplyTo = inReplyToDetails,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
isThreaded = true,
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
@@ -150,7 +156,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||
)
|
||||
}
|
||||
|
||||
private fun aMessageContent(
|
||||
protected fun aMessageContent(
|
||||
body: String,
|
||||
type: MessageType,
|
||||
) = MessageContent(
|
||||
@@ -163,12 +169,24 @@ open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails>
|
||||
|
||||
protected fun aInReplyToDetails(
|
||||
eventContent: EventContent,
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
) = InReplyToDetails(
|
||||
eventId = EventId("\$event"),
|
||||
eventContent = eventContent,
|
||||
senderId = UserId("@Sender:domain"),
|
||||
senderDisplayName = "Sender",
|
||||
senderAvatarUrl = null,
|
||||
senderProfile = aProfileTimelineDetailsReady(
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
),
|
||||
textContent = (eventContent as? MessageContent)?.body.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aProfileTimelineDetailsReady(
|
||||
displayName: String? = "Sender",
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
avatarUrl: String? = null,
|
||||
) = ProfileTimelineDetails.Ready(
|
||||
displayName = displayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
||||
@@ -207,7 +207,7 @@ private fun computeReceiptDescription(receipts: ImmutableList<ReadReceiptData>):
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemReactionsViewPreview(
|
||||
internal fun TimelineItemReadReceiptViewPreview(
|
||||
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
|
||||
) = ElementPreview {
|
||||
TimelineItemReadReceiptView(
|
||||
|
||||
@@ -52,8 +52,12 @@ class TimelineItemContentFactory @Inject constructor(
|
||||
is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent)
|
||||
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
|
||||
is MessageContent -> {
|
||||
val senderDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
|
||||
messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
|
||||
val senderDisambiguatedDisplayName = eventTimelineItem.senderProfile.getDisambiguatedDisplayName(eventTimelineItem.sender)
|
||||
messageFactory.create(
|
||||
content = itemContent,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
eventId = eventTimelineItem.eventId,
|
||||
)
|
||||
}
|
||||
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
|
||||
is RedactedContent -> redactedMessageFactory.create(itemContent)
|
||||
|
||||
@@ -70,17 +70,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) {
|
||||
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
|
||||
suspend fun create(
|
||||
content: MessageContent,
|
||||
senderDisambiguatedDisplayName: String,
|
||||
eventId: EventId?,
|
||||
): TimelineItemEventContent {
|
||||
return when (val messageType = content.type) {
|
||||
is EmoteMessageType -> {
|
||||
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
|
||||
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
|
||||
TimelineItemEmoteContent(
|
||||
body = emoteBody,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(
|
||||
permalinkParser = permalinkParser,
|
||||
prefix = "* $senderDisplayName",
|
||||
prefix = "* $senderDisambiguatedDisplayName",
|
||||
),
|
||||
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
|
||||
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: emoteBody.withLinks(),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.text.DateFormat
|
||||
@@ -55,15 +55,14 @@ class TimelineItemEventFactory @Inject constructor(
|
||||
val currentSender = currentTimelineItem.event.sender
|
||||
val groupPosition =
|
||||
computeGroupPosition(currentTimelineItem, timelineItems, index)
|
||||
val (senderDisplayName, senderAvatarUrl) = currentTimelineItem.getSenderInfo()
|
||||
|
||||
val senderProfile = currentTimelineItem.event.senderProfile
|
||||
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp))
|
||||
|
||||
val senderAvatarData = AvatarData(
|
||||
id = currentSender.value,
|
||||
name = senderDisplayName ?: currentSender.value,
|
||||
url = senderAvatarUrl,
|
||||
name = senderProfile.getDisambiguatedDisplayName(currentSender),
|
||||
url = senderProfile.getAvatarUrl(),
|
||||
size = AvatarSize.TimelineSender
|
||||
)
|
||||
currentTimelineItem.event
|
||||
@@ -72,7 +71,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||
eventId = currentTimelineItem.eventId,
|
||||
transactionId = currentTimelineItem.transactionId,
|
||||
senderId = currentSender,
|
||||
senderDisplayName = senderDisplayName,
|
||||
senderProfile = senderProfile,
|
||||
senderAvatar = senderAvatarData,
|
||||
content = contentFactory.create(currentTimelineItem.event),
|
||||
isMine = currentTimelineItem.event.isOwn,
|
||||
@@ -99,26 +98,6 @@ class TimelineItemEventFactory @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun MatrixTimelineItem.Event.getSenderInfo(): Pair<String?, String?> {
|
||||
val senderDisplayName: String?
|
||||
val senderAvatarUrl: String?
|
||||
|
||||
when (val senderProfile = event.senderProfile) {
|
||||
ProfileTimelineDetails.Unavailable,
|
||||
ProfileTimelineDetails.Pending,
|
||||
is ProfileTimelineDetails.Error -> {
|
||||
senderDisplayName = null
|
||||
senderAvatarUrl = null
|
||||
}
|
||||
is ProfileTimelineDetails.Ready -> {
|
||||
senderDisplayName = senderProfile.getDisambiguatedDisplayName(event.sender)
|
||||
senderAvatarUrl = senderProfile.avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
return senderDisplayName to senderAvatarUrl
|
||||
}
|
||||
|
||||
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
|
||||
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
var aggregatedReactions = event.reactions.map { reaction ->
|
||||
|
||||
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
@@ -29,8 +30,7 @@ import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
data class InReplyToDetails(
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val senderDisplayName: String?,
|
||||
val senderAvatarUrl: String?,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val eventContent: EventContent?,
|
||||
val textContent: String?,
|
||||
)
|
||||
@@ -41,8 +41,7 @@ fun InReplyTo.map(
|
||||
is InReplyTo.Ready -> InReplyToDetails(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderDisplayName = senderDisplayName,
|
||||
senderAvatarUrl = senderAvatarUrl,
|
||||
senderProfile = senderProfile,
|
||||
eventContent = content,
|
||||
textContent = when (content) {
|
||||
is MessageContent -> {
|
||||
|
||||
@@ -27,7 +27,9 @@ import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
@@ -65,7 +67,7 @@ sealed interface TimelineItem {
|
||||
val eventId: EventId? = null,
|
||||
val transactionId: TransactionId? = null,
|
||||
val senderId: UserId,
|
||||
val senderDisplayName: String?,
|
||||
val senderProfile: ProfileTimelineDetails,
|
||||
val senderAvatar: AvatarData,
|
||||
val content: TimelineItemEventContent,
|
||||
val sentTime: String = "",
|
||||
@@ -82,7 +84,7 @@ sealed interface TimelineItem {
|
||||
) : TimelineItem {
|
||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||
|
||||
val safeSenderName: String = senderDisplayName ?: senderId.value
|
||||
val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
|
||||
|
||||
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.fixtures
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
|
||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -48,7 +49,7 @@ internal fun aMessageEvent(
|
||||
id = eventId?.value.orEmpty(),
|
||||
eventId = eventId,
|
||||
senderId = A_USER_ID,
|
||||
senderDisplayName = A_USER_NAME,
|
||||
senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME),
|
||||
senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender),
|
||||
content = content,
|
||||
sentTime = "",
|
||||
|
||||
@@ -82,7 +82,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -100,7 +100,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemLocationContent(
|
||||
@@ -116,7 +116,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = LocationMessageType("body", "", null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -134,7 +134,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = TextMessageType("body", null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -152,7 +152,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = TextMessageType("https://www.example.org", null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
) as TimelineItemTextContent
|
||||
val expected = TimelineItemTextContent(
|
||||
@@ -200,7 +200,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
|
||||
@@ -218,7 +218,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
|
||||
@@ -229,7 +229,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VideoMessageType("body", null, null, MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
@@ -277,7 +277,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVideoContent(
|
||||
@@ -303,7 +303,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = AudioMessageType("body", MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemAudioContent(
|
||||
@@ -332,7 +332,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemAudioContent(
|
||||
@@ -351,7 +351,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVoiceContent(
|
||||
@@ -384,7 +384,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
),
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemVoiceContent(
|
||||
@@ -409,7 +409,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = VoiceMessageType("body", MediaSource("url"), null, null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemAudioContent(
|
||||
@@ -428,7 +428,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = ImageMessageType("body", null, null, MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
@@ -499,7 +499,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemImageContent(
|
||||
@@ -524,7 +524,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = FileMessageType("body", MediaSource("url"), null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemFileContent(
|
||||
@@ -559,7 +559,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
)
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemFileContent(
|
||||
@@ -578,7 +578,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = NoticeMessageType("body", null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemNoticeContent(
|
||||
@@ -601,7 +601,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
|
||||
@@ -612,7 +612,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
val sut = createTimelineItemContentMessageFactory()
|
||||
val result = sut.create(
|
||||
content = createMessageContent(type = EmoteMessageType("body", null)),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
val expected = TimelineItemEmoteContent(
|
||||
@@ -635,7 +635,7 @@ class TimelineItemContentMessageFactoryTest {
|
||||
formatted = FormattedBody(MessageFormat.HTML, "formatted")
|
||||
)
|
||||
),
|
||||
senderDisplayName = "Bob",
|
||||
senderDisambiguatedDisplayName = "Bob",
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.components.aProfileTimelineDetailsReady
|
||||
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
@@ -39,7 +40,7 @@ class TimelineItemGrouperTest {
|
||||
id = "0",
|
||||
senderId = A_USER_ID,
|
||||
senderAvatar = anAvatarData(),
|
||||
senderDisplayName = "",
|
||||
senderProfile = aProfileTimelineDetailsReady(displayName = ""),
|
||||
content = TimelineItemStateEventContent(body = "a state event"),
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||
|
||||
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
|
||||
import org.junit.Test
|
||||
|
||||
class InReplyToDetailTest {
|
||||
@@ -54,8 +55,7 @@ class InReplyToDetailTest {
|
||||
val inReplyTo = InReplyTo.Ready(
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID,
|
||||
senderDisplayName = "senderDisplayName",
|
||||
senderAvatarUrl = "senderAvatarUrl",
|
||||
senderProfile = aProfileTimelineDetails(),
|
||||
content = RoomMembershipContent(
|
||||
userId = A_USER_ID,
|
||||
change = MembershipChange.INVITED,
|
||||
@@ -73,8 +73,7 @@ class InReplyToDetailTest {
|
||||
val inReplyTo = InReplyTo.Ready(
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID,
|
||||
senderDisplayName = "senderDisplayName",
|
||||
senderAvatarUrl = "senderAvatarUrl",
|
||||
senderProfile = aProfileTimelineDetails(),
|
||||
content = MessageContent(
|
||||
body = "**Hello!**",
|
||||
inReplyTo = null,
|
||||
@@ -101,8 +100,7 @@ class InReplyToDetailTest {
|
||||
val inReplyTo = InReplyTo.Ready(
|
||||
eventId = AN_EVENT_ID,
|
||||
senderId = A_USER_ID,
|
||||
senderDisplayName = "senderDisplayName",
|
||||
senderAvatarUrl = "senderAvatarUrl",
|
||||
senderProfile = aProfileTimelineDetails(),
|
||||
content = MessageContent(
|
||||
body = "**Hello!**",
|
||||
inReplyTo = null,
|
||||
|
||||
@@ -42,6 +42,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
@@ -55,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import io.element.android.libraries.matrix.test.timeline.aMessageContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aPollContent
|
||||
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
@@ -430,15 +432,13 @@ class InReplyToMetadataKtTest {
|
||||
fun anInReplyToDetails(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
senderId: UserId = A_USER_ID,
|
||||
senderDisplayName: String? = "senderDisplayName",
|
||||
senderAvatarUrl: String? = "senderAvatarUrl",
|
||||
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
||||
eventContent: EventContent? = aMessageContent(),
|
||||
textContent: String? = "textContent",
|
||||
) = InReplyToDetails(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderDisplayName = senderDisplayName,
|
||||
senderAvatarUrl = senderAvatarUrl,
|
||||
senderProfile = senderProfile,
|
||||
eventContent = eventContent,
|
||||
textContent = textContent,
|
||||
)
|
||||
|
||||
27
features/migration/api/build.gradle.kts
Normal file
27
features/migration/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
44
features/migration/impl/build.gradle.kts
Normal file
44
features/migration/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -202,7 +202,7 @@ private fun OnBoardingButtons(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun OnBoardingScreenPreview(
|
||||
internal fun OnBoardingViewPreview(
|
||||
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
|
||||
) = ElementPreview {
|
||||
OnBoardingView(
|
||||
|
||||
@@ -132,7 +132,7 @@ internal fun PollAnswerView(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewDisclosedNotSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = false),
|
||||
)
|
||||
@@ -140,7 +140,7 @@ internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewDisclosedSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = true),
|
||||
)
|
||||
@@ -148,7 +148,7 @@ internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewUndisclosedNotSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = false, isSelected = false),
|
||||
)
|
||||
@@ -156,7 +156,7 @@ internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewUndisclosedSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = false, isSelected = true),
|
||||
)
|
||||
@@ -164,7 +164,7 @@ internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewEndedWinnerNotSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = false, isEnabled = false, isWinner = true),
|
||||
)
|
||||
@@ -172,7 +172,7 @@ internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewEndedWinnerSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = true),
|
||||
)
|
||||
@@ -180,7 +180,7 @@ internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
|
||||
internal fun PollAnswerViewEndedSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false),
|
||||
)
|
||||
|
||||
@@ -241,7 +241,7 @@ private fun CreatorView(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentUndisclosedPreview() = ElementPreview {
|
||||
internal fun PollContentViewUndisclosedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
@@ -258,7 +258,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentDisclosedPreview() = ElementPreview {
|
||||
internal fun PollContentViewDisclosedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
@@ -275,7 +275,7 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentEndedPreview() = ElementPreview {
|
||||
internal fun PollContentViewEndedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
@@ -292,7 +292,7 @@ internal fun PollContentEndedPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentCreatorEditablePreview() = ElementPreview {
|
||||
internal fun PollContentViewCreatorEditablePreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
@@ -309,7 +309,7 @@ internal fun PollContentCreatorEditablePreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentCreatorPreview() = ElementPreview {
|
||||
internal fun PollContentViewCreatorPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
@@ -326,7 +326,7 @@ internal fun PollContentCreatorPreview() = ElementPreview {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PollContentCreatorEndedPreview() = ElementPreview {
|
||||
internal fun PollContentViewCreatorEndedPreview() = ElementPreview {
|
||||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
|
||||
@@ -25,22 +25,36 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
|
||||
override val values: Sequence<DeveloperSettingsState>
|
||||
get() = sequenceOf(
|
||||
aDeveloperSettingsState(),
|
||||
aDeveloperSettingsState().copy(clearCacheAction = AsyncData.Loading()),
|
||||
aDeveloperSettingsState().copy(
|
||||
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
|
||||
aDeveloperSettingsState(
|
||||
clearCacheAction = AsyncData.Loading()
|
||||
),
|
||||
aDeveloperSettingsState(
|
||||
customElementCallBaseUrlState = aCustomElementCallBaseUrlState(
|
||||
baseUrl = "https://call.element.ahoy",
|
||||
defaultUrl = "https://call.element.io",
|
||||
validator = { true }
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aDeveloperSettingsState() = DeveloperSettingsState(
|
||||
fun aDeveloperSettingsState(
|
||||
clearCacheAction: AsyncData<Unit> = AsyncData.Uninitialized,
|
||||
customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(),
|
||||
eventSink: (DeveloperSettingsEvents) -> Unit = {},
|
||||
) = DeveloperSettingsState(
|
||||
features = aFeatureUiModelList(),
|
||||
rageshakeState = aRageshakePreferencesState(),
|
||||
cacheSize = AsyncData.Success("1.2 MB"),
|
||||
clearCacheAction = AsyncData.Uninitialized,
|
||||
customElementCallBaseUrlState = CustomElementCallBaseUrlState(baseUrl = null, defaultUrl = "https://call.element.io", validator = { true }),
|
||||
eventSink = {}
|
||||
clearCacheAction = clearCacheAction,
|
||||
customElementCallBaseUrlState = customElementCallBaseUrlState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aCustomElementCallBaseUrlState(
|
||||
baseUrl: String? = null,
|
||||
defaultUrl: String = "https://call.element.io",
|
||||
validator: (String?) -> Boolean = { true },
|
||||
) = CustomElementCallBaseUrlState(
|
||||
baseUrl = baseUrl,
|
||||
defaultUrl = defaultUrl,
|
||||
validator = validator,
|
||||
)
|
||||
|
||||
@@ -42,16 +42,21 @@ private fun anEditDefaultNotificationSettingsState(
|
||||
) = EditDefaultNotificationSettingState(
|
||||
isOneToOne = isOneToOne,
|
||||
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
|
||||
roomsWithUserDefinedMode = persistentListOf(aRoomSummary()),
|
||||
roomsWithUserDefinedMode = persistentListOf(
|
||||
aRoomSummary("Room"),
|
||||
aRoomSummary(null),
|
||||
),
|
||||
changeNotificationSettingAction = changeNotificationSettingAction,
|
||||
displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
private fun aRoomSummary() = RoomSummary.Filled(
|
||||
private fun aRoomSummary(
|
||||
name: String?,
|
||||
) = RoomSummary.Filled(
|
||||
aRoomSummaryDetails(
|
||||
roomId = RoomId("!roomId:domain"),
|
||||
name = "Room",
|
||||
name = name,
|
||||
avatarUrl = null,
|
||||
isDirect = false,
|
||||
lastMessage = null,
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
@@ -100,7 +101,11 @@ fun EditDefaultNotificationSettingView(
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = summary.details.name)
|
||||
val roomName = summary.details.name
|
||||
Text(
|
||||
text = roomName ?: stringResource(id = CommonStrings.common_no_room_name),
|
||||
fontStyle = FontStyle.Italic.takeIf { roomName == null }
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = subtitle)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -26,10 +26,10 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.features.rageshake.api.crash.CrashDataStore
|
||||
import io.element.android.features.rageshake.api.logs.LogFilesRemover
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporterListener
|
||||
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
|
||||
import io.element.android.features.rageshake.impl.logs.VectorFileLogger
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -40,6 +40,7 @@ class BugReportPresenter @Inject constructor(
|
||||
private val bugReporter: BugReporter,
|
||||
private val crashDataStore: CrashDataStore,
|
||||
private val screenshotHolder: ScreenshotHolder,
|
||||
private val logFilesRemover: LogFilesRemover,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
) : Presenter<BugReportState> {
|
||||
private class BugReporterUploadListener(
|
||||
@@ -150,6 +151,6 @@ class BugReportPresenter @Inject constructor(
|
||||
private fun CoroutineScope.resetAll() = launch {
|
||||
screenshotHolder.reset()
|
||||
crashDataStore.reset()
|
||||
VectorFileLogger.getFromTimber()?.reset()
|
||||
logFilesRemover.perform()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,12 @@ class DefaultBugReporter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAllFiles() {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
getLogFiles().forEach { it.safeDelete() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCurrentTracingFilter(tracingFilter: String) {
|
||||
currentTracingFilter = tracingFilter
|
||||
}
|
||||
@@ -374,7 +380,6 @@ class DefaultBugReporter @Inject constructor(
|
||||
|
||||
/**
|
||||
* Delete all the log files except the most recent one.
|
||||
*
|
||||
*/
|
||||
private fun List<File>.deleteAllExceptMostRecent() {
|
||||
if (size > 1) {
|
||||
|
||||
@@ -21,15 +21,18 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.rageshake.api.crash.CrashDataStore
|
||||
import io.element.android.features.rageshake.api.logs.LogFilesRemover
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
|
||||
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
|
||||
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
|
||||
import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover
|
||||
import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
|
||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@@ -117,9 +120,11 @@ class BugReportPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - reset all`() = runTest {
|
||||
val logFilesRemoverLambda = lambdaRecorder { -> }
|
||||
val presenter = createPresenter(
|
||||
crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
|
||||
screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
|
||||
logFilesRemover = FakeLogFilesRemover(logFilesRemoverLambda),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
@@ -131,6 +136,7 @@ class BugReportPresenterTest {
|
||||
initialState.eventSink.invoke(BugReportEvents.ResetAll)
|
||||
val resetState = awaitItem()
|
||||
assertThat(resetState.hasCrashLogs).isFalse()
|
||||
logFilesRemoverLambda.assertions().isCalledExactly(1)
|
||||
// TODO Make it live assertThat(resetState.screenshotUri).isNull()
|
||||
}
|
||||
}
|
||||
@@ -239,10 +245,12 @@ class BugReportPresenterTest {
|
||||
bugReporter: BugReporter = FakeBugReporter(),
|
||||
crashDataStore: CrashDataStore = FakeCrashDataStore(),
|
||||
screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(),
|
||||
logFilesRemover: LogFilesRemover = FakeLogFilesRemover(lambdaRecorder(ensureNeverCalled = true) { -> }),
|
||||
) = BugReportPresenter(
|
||||
bugReporter,
|
||||
crashDataStore,
|
||||
screenshotHolder,
|
||||
this,
|
||||
bugReporter = bugReporter,
|
||||
crashDataStore = crashDataStore,
|
||||
screenshotHolder = screenshotHolder,
|
||||
logFilesRemover = logFilesRemover,
|
||||
appCoroutineScope = this,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -24,4 +24,5 @@ android {
|
||||
dependencies {
|
||||
implementation(projects.features.rageshake.api)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.roomaliasresolver.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
@@ -44,10 +49,13 @@ dependencies {
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -36,6 +39,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.background.LightGradientBackground
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -61,22 +65,26 @@ fun RoomAliasResolverView(
|
||||
latestOnAliasResolved(state.resolveState.data)
|
||||
}
|
||||
}
|
||||
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
paddingValues = PaddingValues(16.dp),
|
||||
topBar = {
|
||||
RoomAliasResolverTopBar(onBackClicked = onBackPressed)
|
||||
},
|
||||
content = {
|
||||
RoomAliasResolverContent(state = state)
|
||||
},
|
||||
footer = {
|
||||
RoomAliasResolverFooter(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
)
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
LightGradientBackground()
|
||||
HeaderFooterPage(
|
||||
containerColor = Color.Transparent,
|
||||
paddingValues = PaddingValues(16.dp),
|
||||
topBar = {
|
||||
RoomAliasResolverTopBar(onBackClicked = onBackPressed)
|
||||
},
|
||||
content = {
|
||||
RoomAliasResolverContent(state = state)
|
||||
},
|
||||
footer = {
|
||||
RoomAliasResolverFooter(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -92,7 +100,7 @@ private fun RoomAliasResolverFooter(
|
||||
state.eventSink(RoomAliasResolverEvents.Retry)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
size = ButtonSize.Large,
|
||||
)
|
||||
}
|
||||
is AsyncData.Loading -> {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@ private fun RoomInviteMembersSearchBar(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomInviteMembersPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
|
||||
internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
|
||||
RoomInviteMembersView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user