Merge branch 'develop' into feature/fga/space_list_join_action

This commit is contained in:
ganfra 2025-09-29 18:01:42 +02:00
commit 0390fde615
591 changed files with 6155 additions and 2140 deletions

View file

@ -0,0 +1,18 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.accountselect.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.SessionId
interface AccountSelectEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onSelectAccount(sessionId: SessionId)
fun onCancel()
}
}

View file

@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.accountselect.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.libraries.accountselect.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
}

View file

@ -0,0 +1,49 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(AppScope::class)
@AssistedInject
class AccountSelectNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AccountSelectPresenter,
) : Node(buildContext, plugins = plugins) {
private val callbacks = plugins.filterIsInstance<AccountSelectEntryPoint.Callback>()
private fun onDismiss() {
callbacks.forEach { it.onCancel() }
}
private fun onSelectAccount(sessionId: SessionId) {
callbacks.forEach { it.onSelectAccount(sessionId) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AccountSelectView(
state = state,
onDismiss = ::onDismiss,
onSelectAccount = ::onSelectAccount,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Inject
class AccountSelectPresenter(
private val sessionStore: SessionStore,
) : Presenter<AccountSelectState> {
@Composable
override fun present(): AccountSelectState {
val accounts by produceState(persistentListOf()) {
// Do not use sessionStore.sessionsFlow() to not make it change when an account is selected.
value = sessionStore.getAllSessions()
.map {
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
)
}
.toPersistentList()
}
return AccountSelectState(
accounts = accounts,
)
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class AccountSelectState(
val accounts: ImmutableList<MatrixUser>,
)

View file

@ -0,0 +1,27 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toPersistentList
open class AccountSelectStateProvider : PreviewParameterProvider<AccountSelectState> {
override val values: Sequence<AccountSelectState>
get() = sequenceOf(
anAccountSelectState(),
anAccountSelectState(accounts = aMatrixUserList()),
)
}
private fun anAccountSelectState(
accounts: List<MatrixUser> = listOf(),
) = AccountSelectState(
accounts = accounts.toPersistentList(),
)

View file

@ -0,0 +1,88 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
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.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
@Suppress("MultipleEmitters") // False positive
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSelectView(
state: AccountSelectState,
onSelectAccount: (SessionId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = { onDismiss() })
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.common_select_account),
navigationIcon = {
BackButton(onClick = { onDismiss() })
},
)
}
) { paddingValues ->
Column(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
) {
LazyColumn {
items(state.accounts, key = { it.userId }) { matrixUser ->
Column {
MatrixUserRow(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSelectAccount(matrixUser.userId)
}
.padding(vertical = 8.dp),
matrixUser = matrixUser,
)
HorizontalDivider()
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview {
AccountSelectView(
state = state,
onSelectAccount = {},
onDismiss = {},
)
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@Inject
class DefaultAccountSelectEntryPoint : AccountSelectEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): AccountSelectEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : AccountSelectEntryPoint.NodeBuilder {
override fun callback(callback: AccountSelectEntryPoint.Callback): AccountSelectEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<AccountSelectNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AccountSelectPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createAccountSelectPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accounts).isEmpty()
}
}
@Test
fun `present - multiple accounts case`() = runTest {
val presenter = createAccountSelectPresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(
sessionId = A_SESSION_ID_2.value,
userDisplayName = "Bob",
userAvatarUrl = "avatarUrl",
),
)
)
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.accounts).hasSize(2)
val firstAccount = initialState.accounts[0]
assertThat(firstAccount).isEqualTo(
MatrixUser(
userId = A_SESSION_ID,
displayName = null,
avatarUrl = null,
)
)
val secondAccount = initialState.accounts[1]
assertThat(secondAccount).isEqualTo(
MatrixUser(
userId = A_SESSION_ID_2,
displayName = "Bob",
avatarUrl = "avatarUrl",
)
)
}
}
}
internal fun createAccountSelectPresenter(
sessionStore: SessionStore = InMemorySessionStore(),
) = AccountSelectPresenter(
sessionStore = sessionStore,
)

View file

@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.accountselect.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultAccountSelectEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultAccountSelectEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
AccountSelectNode(
buildContext = buildContext,
plugins = plugins,
presenter = createAccountSelectPresenter(),
)
}
val callback = object : AccountSelectEntryPoint.Callback {
override fun onSelectAccount(sessionId: SessionId) = lambdaError()
override fun onCancel() = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result).isInstanceOf(AccountSelectNode::class.java)
assertThat(result.plugins).contains(callback)
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.architecture.appyx
import android.annotation.SuppressLint
import androidx.compose.animation.core.Transition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
/**
* A [ModifierTransitionHandler] that delegates the creation of the modifier to another handler
* based on the [NavTarget]. The idea is to allow different transitions for different [NavTarget]s.
*/
class DelegateTransitionHandler<NavTarget, State>(
private val handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
) : ModifierTransitionHandler<NavTarget, State>() {
@SuppressLint("ModifierFactoryExtensionFunction")
override fun createModifier(modifier: Modifier, transition: Transition<State>, descriptor: TransitionDescriptor<NavTarget, State>): Modifier {
return handlerProvider(descriptor.element).createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget, State> rememberDelegateTransitionHandler(
handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
): ModifierTransitionHandler<NavTarget, State> =
remember(handlerProvider) { DelegateTransitionHandler(handlerProvider = handlerProvider) }

View file

@ -8,9 +8,18 @@
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
data class CoroutineDispatchers(
val io: CoroutineDispatcher,
val computation: CoroutineDispatcher,
val main: CoroutineDispatcher,
)
) {
companion object {
val Default = CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
}
}

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s itt: %2$s"</string>
<string name="common_date_date_at_time">"%1$s, %2$s"</string>
<string name="common_date_this_month">"Ebben a hónapban"</string>
</resources>

View file

@ -72,4 +72,7 @@ enum class AvatarSize(val dp: Dp) {
RoomPreviewHeader(64.dp),
RoomPreviewInviter(56.dp),
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
AccountItem(32.dp),
}

View file

@ -26,7 +26,11 @@
<string name="state_event_room_knock_accepted">"%1$s получи достъп до %2$s"</string>
<string name="state_event_room_knock_accepted_by_you">"Вие позволихте на %1$s да се присъедини"</string>
<string name="state_event_room_knock_by_you">"Вие поискахте да се присъедините"</string>
<string name="state_event_room_knock_denied">"%1$s отхвърли заявката на %2$s за присъединяване"</string>
<string name="state_event_room_knock_denied_by_you">"Вие отхвърлихте заявката на %1$s за присъединяване"</string>
<string name="state_event_room_knock_denied_you">"%1$s отхвърли вашата заявка за присъединяване"</string>
<string name="state_event_room_knock_retracted">"%1$s вече не се интересува от присъединяване"</string>
<string name="state_event_room_knock_retracted_by_you">"Вие отменихте заявката си за присъединяване"</string>
<string name="state_event_room_leave">"%1$s напусна стаята"</string>
<string name="state_event_room_leave_by_you">"Вие напуснахте стаята"</string>
<string name="state_event_room_name_changed">"%1$s промени името на стаята на: %2$s"</string>
@ -39,14 +43,19 @@
<string name="state_event_room_pinned_events_changed_by_you">"Вие променихте закачените съобщения"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s закачи съобщение"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"Вие закачихте съобщение"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s откачи съобщение"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"Вие откачихте съобщение"</string>
<string name="state_event_room_reject">"%1$s отхвърли поканата"</string>
<string name="state_event_room_reject_by_you">"Вие отхвърлихте поканата"</string>
<string name="state_event_room_remove">"%1$s премахна %2$s"</string>
<string name="state_event_room_remove_by_you">"Вие премахнахте %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s изпрати покана на %2$s за присъединяване към стаята"</string>
<string name="state_event_room_third_party_invite_by_you">"Вие изпратихте покана на %1$s за присъединяване към стаята"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s отмени поканата на %2$s за присъединяване към стаята"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Вие отменихте поканата на %1$s за присъединяване към стаята"</string>
<string name="state_event_room_topic_changed">"%1$s промени темата на: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Вие променихте темата на: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s премахна темата на стаята"</string>
<string name="state_event_room_topic_removed_by_you">"Вие премахнахте темата на стаята"</string>
<string name="state_event_room_unknown_membership_change">"%1$s направи неизвестна промяна в членството си"</string>
</resources>

View file

@ -99,5 +99,13 @@ enum class FeatureFlags(
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
defaultValue = { false },
isFinished = false,
)
),
MultiAccount(
key = "feature.multi_account",
title = "Multi accounts",
description = "Allow the application to connect to multiple accounts at the same time." +
"\n\nWARNING: this feature is EXPERIMENTAL and UNSTABLE.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -31,9 +31,8 @@ import org.maplibre.android.maps.Projection
*/
@Composable
public inline fun rememberCameraPositionState(
key: String? = null,
crossinline init: CameraPositionState.() -> Unit = {}
): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) {
): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) {
CameraPositionState().apply(init)
}

View file

@ -8,7 +8,7 @@
package io.element.android.libraries.maplibre.compose
import android.content.ComponentCallbacks
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
@ -235,11 +235,15 @@ private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Even
previousState.value = event
}
private fun MapView.componentCallbacks(): ComponentCallbacks =
object : ComponentCallbacks {
override fun onConfigurationChanged(config: Configuration) {}
private fun MapView.componentCallbacks(): ComponentCallbacks2 =
object : ComponentCallbacks2 {
override fun onConfigurationChanged(config: Configuration) = Unit
override fun onLowMemory() {
@Suppress("OVERRIDE_DEPRECATION")
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
// We call the `MapView.onLowMemory` method for any memory trim level
this@componentCallbacks.onLowMemory()
}
}

View file

@ -60,9 +60,8 @@ public class SymbolState(
@Composable
public fun rememberSymbolState(
key: String? = null,
position: LatLng = LatLng(0.0, 0.0)
): SymbolState = rememberSaveable(key = key, saver = SymbolState.Saver) {
): SymbolState = rememberSaveable(saver = SymbolState.Saver) {
SymbolState(position)
}

View file

@ -173,6 +173,16 @@ interface MatrixClient {
* Returns the maximum file upload size allowed by the Matrix server.
*/
suspend fun getMaxFileUploadSize(): Result<Long>
/**
* Returns the list of shared recent emoji reactions for this account.
*/
suspend fun getRecentEmojis(): Result<List<String>>
/**
* Adds an emoji to the list of recent emoji reactions for this account.
*/
suspend fun addRecentEmoji(emoji: String): Result<Unit>
}
/**

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.auth
sealed class AuthenticationException(message: String) : Exception(message) {
class AccountAlreadyLoggedIn(userId: String) : AuthenticationException(userId)
class InvalidServerName(message: String) : AuthenticationException(message)
class SlidingSyncVersion(message: String) : AuthenticationException(message)
class Oidc(message: String) : AuthenticationException(message)

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -15,13 +16,15 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.parcelize.Parcelize
/**
* This sealed class represents all the permalink cases.
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
*/
@Immutable
sealed interface PermalinkData {
@Parcelize
sealed interface PermalinkData : Parcelable {
data class RoomLink(
val roomIdOrAlias: RoomIdOrAlias,
val eventId: EventId? = null,

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.recentemojis
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
@Inject
class AddRecentEmoji(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
) {
suspend operator fun invoke(emoji: String): Result<Unit> = withContext(dispatchers.io) {
client.addRecentEmoji(emoji)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.recentemojis
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
fun interface GetRecentEmojis {
suspend operator fun invoke(): Result<List<String>>
}
@ContributesBinding(SessionScope::class)
@Inject
class DefaultGetRecentEmojis(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
) : GetRecentEmojis {
override suspend operator fun invoke(): Result<List<String>> = withContext(dispatchers.io) {
client.getRecentEmojis()
}
}

View file

@ -7,28 +7,32 @@
package io.element.android.libraries.matrix.api.room
enum class MessageEventType {
CALL_ANSWER,
CALL_INVITE,
CALL_HANGUP,
CALL_CANDIDATES,
RTC_NOTIFICATION,
KEY_VERIFICATION_READY,
KEY_VERIFICATION_START,
KEY_VERIFICATION_CANCEL,
KEY_VERIFICATION_ACCEPT,
KEY_VERIFICATION_KEY,
KEY_VERIFICATION_MAC,
KEY_VERIFICATION_DONE,
REACTION,
ROOM_ENCRYPTED,
ROOM_MESSAGE,
ROOM_REDACTION,
STICKER,
POLL_END,
POLL_RESPONSE,
POLL_START,
UNSTABLE_POLL_END,
UNSTABLE_POLL_RESPONSE,
UNSTABLE_POLL_START,
import androidx.compose.runtime.Immutable
@Immutable
sealed interface MessageEventType {
data object CallAnswer : MessageEventType
data object CallInvite : MessageEventType
data object CallHangup : MessageEventType
data object CallCandidates : MessageEventType
data object RtcNotification : MessageEventType
data object KeyVerificationReady : MessageEventType
data object KeyVerificationStart : MessageEventType
data object KeyVerificationCancel : MessageEventType
data object KeyVerificationAccept : MessageEventType
data object KeyVerificationKey : MessageEventType
data object KeyVerificationMac : MessageEventType
data object KeyVerificationDone : MessageEventType
data object Reaction : MessageEventType
data object RoomEncrypted : MessageEventType
data object RoomMessage : MessageEventType
data object RoomRedaction : MessageEventType
data object Sticker : MessageEventType
data object PollEnd : MessageEventType
data object PollResponse : MessageEventType
data object PollStart : MessageEventType
data object UnstablePollEnd : MessageEventType
data object UnstablePollResponse : MessageEventType
data object UnstablePollStart : MessageEventType
data class Other(val type: String) : MessageEventType
}

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.asSharedFlow
class RoomMembershipObserver {
data class RoomMembershipUpdate(
val roomId: RoomId,
val isSpace: Boolean,
val isUserInRoom: Boolean,
val change: MembershipChange,
)
@ -22,12 +23,23 @@ class RoomMembershipObserver {
private val _updates = MutableSharedFlow<RoomMembershipUpdate>(extraBufferCapacity = 10)
val updates = _updates.asSharedFlow()
suspend fun notifyUserLeftRoom(roomId: RoomId, membershipBeforeLeft: CurrentUserMembership) {
suspend fun notifyUserLeftRoom(
roomId: RoomId,
isSpace: Boolean,
membershipBeforeLeft: CurrentUserMembership,
) {
val membershipChange = when (membershipBeforeLeft) {
CurrentUserMembership.INVITED -> MembershipChange.INVITATION_REJECTED
CurrentUserMembership.KNOCKED -> MembershipChange.KNOCK_RETRACTED
else -> MembershipChange.LEFT
}
_updates.emit(RoomMembershipUpdate(roomId, false, membershipChange))
_updates.emit(
RoomMembershipUpdate(
roomId = roomId,
isSpace = isSpace,
isUserInRoom = false,
change = membershipChange,
)
)
}
}

View file

@ -37,7 +37,7 @@ dependencies {
implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api)
implementation(projects.libraries.core)
implementation("net.java.dev.jna:jna:5.17.0@aar")
implementation("net.java.dev.jna:jna:5.18.0@aar")
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
implementation(libs.kotlinx.collections.immutable)

View file

@ -235,7 +235,6 @@ class RustMatrixClient(
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
// TODO cache for displayName?
displayName = null,
avatarUrl = null,
)
@ -264,6 +263,16 @@ class RustMatrixClient(
// Start notification settings
notificationSettingsService.start()
// Update the user profile in the session store if needed
sessionStore.getSession(sessionId.value)?.let { sessionData ->
_userProfile.emit(
MatrixUser(
userId = sessionId,
displayName = sessionData.userDisplayName,
avatarUrl = sessionData.userAvatarUrl,
)
)
}
// Force a refresh of the profile
getUserProfile()
}
@ -399,7 +408,15 @@ class RustMatrixClient(
}
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { _userProfile.tryEmit(it) }
.onSuccess { matrixUser ->
_userProfile.emit(matrixUser)
// Also update our session storage
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
avatarUrl = matrixUser.avatarUrl,
)
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(sessionDispatcher) {
@ -708,6 +725,18 @@ class RustMatrixClient(
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
}
override suspend fun addRecentEmoji(emoji: String): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.addRecentEmoji(emoji)
}
}
override suspend fun getRecentEmojis(): Result<List<String>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getRecentEmojis().map { it.emoji }
}
}
private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {

View file

@ -14,6 +14,7 @@ import org.matrix.rustcomponents.sdk.OidcException
fun Throwable.mapAuthenticationException(): AuthenticationException {
val message = this.message ?: "Unknown error"
return when (this) {
is AuthenticationException -> this
is ClientBuildException -> when (this) {
is ClientBuildException.Generic -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message)

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -139,6 +140,8 @@ class RustMatrixAuthenticationService(
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -227,17 +230,19 @@ class RustMatrixAuthenticationService(
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(callbackUrl)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@ -253,6 +258,21 @@ class RustMatrixAuthenticationService(
}
}
@Throws(AuthenticationException.AccountAlreadyLoggedIn::class)
private suspend fun ensureNotAlreadyLoggedIn(client: Client) {
val newUserId = client.userId()
val accountAlreadyLoggedIn = sessionStore.getAllSessions().any {
it.userId == newUserId
}
if (accountAlreadyLoggedIn) {
// Sign out the client, ignoring any error
runCatchingExceptions {
client.logout()
}
throw AuthenticationException.AccountAlreadyLoggedIn(newUserId)
}
}
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
withContext(coroutineDispatchers.io) {
val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData
@ -275,7 +295,8 @@ class RustMatrixAuthenticationService(
oidcConfiguration = oidcConfiguration,
progressListener = progressListener,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,

View file

@ -34,6 +34,11 @@ internal fun Session.toSessionData(
passphrase = passphrase,
sessionPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
// Note: position and lastUsageIndex will be set by the SessionStore when adding the session
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)
internal fun ExternalSession.toSessionData(
@ -55,4 +60,8 @@ internal fun ExternalSession.toSessionData(
passphrase = passphrase,
sessionPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)

View file

@ -11,53 +11,55 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import org.matrix.rustcomponents.sdk.MessageLikeEventType
fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.CALL_ANSWER -> MessageLikeEventType.CALL_ANSWER
MessageEventType.CALL_INVITE -> MessageLikeEventType.CALL_INVITE
MessageEventType.CALL_HANGUP -> MessageLikeEventType.CALL_HANGUP
MessageEventType.CALL_CANDIDATES -> MessageLikeEventType.CALL_CANDIDATES
MessageEventType.RTC_NOTIFICATION -> MessageLikeEventType.RTC_NOTIFICATION
MessageEventType.KEY_VERIFICATION_READY -> MessageLikeEventType.KEY_VERIFICATION_READY
MessageEventType.KEY_VERIFICATION_START -> MessageLikeEventType.KEY_VERIFICATION_START
MessageEventType.KEY_VERIFICATION_CANCEL -> MessageLikeEventType.KEY_VERIFICATION_CANCEL
MessageEventType.KEY_VERIFICATION_ACCEPT -> MessageLikeEventType.KEY_VERIFICATION_ACCEPT
MessageEventType.KEY_VERIFICATION_KEY -> MessageLikeEventType.KEY_VERIFICATION_KEY
MessageEventType.KEY_VERIFICATION_MAC -> MessageLikeEventType.KEY_VERIFICATION_MAC
MessageEventType.KEY_VERIFICATION_DONE -> MessageLikeEventType.KEY_VERIFICATION_DONE
MessageEventType.REACTION -> MessageLikeEventType.REACTION
MessageEventType.ROOM_ENCRYPTED -> MessageLikeEventType.ROOM_ENCRYPTED
MessageEventType.ROOM_MESSAGE -> MessageLikeEventType.ROOM_MESSAGE
MessageEventType.ROOM_REDACTION -> MessageLikeEventType.ROOM_REDACTION
MessageEventType.STICKER -> MessageLikeEventType.STICKER
MessageEventType.POLL_END -> MessageLikeEventType.POLL_END
MessageEventType.POLL_RESPONSE -> MessageLikeEventType.POLL_RESPONSE
MessageEventType.POLL_START -> MessageLikeEventType.POLL_START
MessageEventType.UNSTABLE_POLL_END -> MessageLikeEventType.UNSTABLE_POLL_END
MessageEventType.UNSTABLE_POLL_RESPONSE -> MessageLikeEventType.UNSTABLE_POLL_RESPONSE
MessageEventType.UNSTABLE_POLL_START -> MessageLikeEventType.UNSTABLE_POLL_START
MessageEventType.CallAnswer -> MessageLikeEventType.CallAnswer
MessageEventType.CallInvite -> MessageLikeEventType.CallInvite
MessageEventType.CallHangup -> MessageLikeEventType.CallHangup
MessageEventType.CallCandidates -> MessageLikeEventType.CallCandidates
MessageEventType.RtcNotification -> MessageLikeEventType.RtcNotification
MessageEventType.KeyVerificationReady -> MessageLikeEventType.KeyVerificationReady
MessageEventType.KeyVerificationStart -> MessageLikeEventType.KeyVerificationStart
MessageEventType.KeyVerificationCancel -> MessageLikeEventType.KeyVerificationCancel
MessageEventType.KeyVerificationAccept -> MessageLikeEventType.KeyVerificationAccept
MessageEventType.KeyVerificationKey -> MessageLikeEventType.KeyVerificationKey
MessageEventType.KeyVerificationMac -> MessageLikeEventType.KeyVerificationMac
MessageEventType.KeyVerificationDone -> MessageLikeEventType.KeyVerificationDone
MessageEventType.Reaction -> MessageLikeEventType.Reaction
MessageEventType.RoomEncrypted -> MessageLikeEventType.RoomEncrypted
MessageEventType.RoomMessage -> MessageLikeEventType.RoomMessage
MessageEventType.RoomRedaction -> MessageLikeEventType.RoomRedaction
MessageEventType.Sticker -> MessageLikeEventType.Sticker
MessageEventType.PollEnd -> MessageLikeEventType.PollEnd
MessageEventType.PollResponse -> MessageLikeEventType.PollResponse
MessageEventType.PollStart -> MessageLikeEventType.PollStart
MessageEventType.UnstablePollEnd -> MessageLikeEventType.UnstablePollEnd
MessageEventType.UnstablePollResponse -> MessageLikeEventType.UnstablePollResponse
MessageEventType.UnstablePollStart -> MessageLikeEventType.UnstablePollStart
is MessageEventType.Other -> MessageLikeEventType.Other(type)
}
fun MessageLikeEventType.map(): MessageEventType = when (this) {
MessageLikeEventType.CALL_ANSWER -> MessageEventType.CALL_ANSWER
MessageLikeEventType.CALL_INVITE -> MessageEventType.CALL_INVITE
MessageLikeEventType.CALL_HANGUP -> MessageEventType.CALL_HANGUP
MessageLikeEventType.CALL_CANDIDATES -> MessageEventType.CALL_CANDIDATES
MessageLikeEventType.RTC_NOTIFICATION -> MessageEventType.RTC_NOTIFICATION
MessageLikeEventType.KEY_VERIFICATION_READY -> MessageEventType.KEY_VERIFICATION_READY
MessageLikeEventType.KEY_VERIFICATION_START -> MessageEventType.KEY_VERIFICATION_START
MessageLikeEventType.KEY_VERIFICATION_CANCEL -> MessageEventType.KEY_VERIFICATION_CANCEL
MessageLikeEventType.KEY_VERIFICATION_ACCEPT -> MessageEventType.KEY_VERIFICATION_ACCEPT
MessageLikeEventType.KEY_VERIFICATION_KEY -> MessageEventType.KEY_VERIFICATION_KEY
MessageLikeEventType.KEY_VERIFICATION_MAC -> MessageEventType.KEY_VERIFICATION_MAC
MessageLikeEventType.KEY_VERIFICATION_DONE -> MessageEventType.KEY_VERIFICATION_DONE
MessageLikeEventType.REACTION -> MessageEventType.REACTION
MessageLikeEventType.ROOM_ENCRYPTED -> MessageEventType.ROOM_ENCRYPTED
MessageLikeEventType.ROOM_MESSAGE -> MessageEventType.ROOM_MESSAGE
MessageLikeEventType.ROOM_REDACTION -> MessageEventType.ROOM_REDACTION
MessageLikeEventType.STICKER -> MessageEventType.STICKER
MessageLikeEventType.POLL_END -> MessageEventType.POLL_END
MessageLikeEventType.POLL_RESPONSE -> MessageEventType.POLL_RESPONSE
MessageLikeEventType.POLL_START -> MessageEventType.POLL_START
MessageLikeEventType.UNSTABLE_POLL_END -> MessageEventType.UNSTABLE_POLL_END
MessageLikeEventType.UNSTABLE_POLL_RESPONSE -> MessageEventType.UNSTABLE_POLL_RESPONSE
MessageLikeEventType.UNSTABLE_POLL_START -> MessageEventType.UNSTABLE_POLL_START
MessageLikeEventType.CallAnswer -> MessageEventType.CallAnswer
MessageLikeEventType.CallInvite -> MessageEventType.CallInvite
MessageLikeEventType.CallHangup -> MessageEventType.CallHangup
MessageLikeEventType.CallCandidates -> MessageEventType.CallCandidates
MessageLikeEventType.RtcNotification -> MessageEventType.RtcNotification
MessageLikeEventType.KeyVerificationReady -> MessageEventType.KeyVerificationReady
MessageLikeEventType.KeyVerificationStart -> MessageEventType.KeyVerificationStart
MessageLikeEventType.KeyVerificationCancel -> MessageEventType.KeyVerificationCancel
MessageLikeEventType.KeyVerificationAccept -> MessageEventType.KeyVerificationAccept
MessageLikeEventType.KeyVerificationKey -> MessageEventType.KeyVerificationKey
MessageLikeEventType.KeyVerificationMac -> MessageEventType.KeyVerificationMac
MessageLikeEventType.KeyVerificationDone -> MessageEventType.KeyVerificationDone
MessageLikeEventType.Reaction -> MessageEventType.Reaction
MessageLikeEventType.RoomEncrypted -> MessageEventType.RoomEncrypted
MessageLikeEventType.RoomMessage -> MessageEventType.RoomMessage
MessageLikeEventType.RoomRedaction -> MessageEventType.RoomRedaction
MessageLikeEventType.Sticker -> MessageEventType.Sticker
MessageLikeEventType.PollEnd -> MessageEventType.PollEnd
MessageLikeEventType.PollResponse -> MessageEventType.PollResponse
MessageLikeEventType.PollStart -> MessageEventType.PollStart
MessageLikeEventType.UnstablePollEnd -> MessageEventType.UnstablePollEnd
MessageLikeEventType.UnstablePollResponse -> MessageEventType.UnstablePollResponse
MessageLikeEventType.UnstablePollStart -> MessageEventType.UnstablePollStart
is MessageLikeEventType.Other -> MessageEventType.Other(v1)
}

View file

@ -157,7 +157,11 @@ class RustBaseRoom(
runCatchingExceptions {
innerRoom.leave()
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(roomId, membershipBeforeLeft)
roomMembershipObserver.notifyUserLeftRoom(
roomId = roomId,
isSpace = roomInfoFlow.value.isSpace,
membershipBeforeLeft = membershipBeforeLeft,
)
}
}

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershi
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map
@ -126,6 +127,7 @@ class TimelineEventContentMapper(
source = kind.source.map(),
)
}
is MsgLikeKind.Other -> UnknownContent
}
}
is TimelineItemContent.ProfileChange -> {

View file

@ -38,7 +38,9 @@ class RustMatrixClientFactoryTest {
fun TestScope.createRustMatrixClientFactory(
baseDirectory: File = File("/base"),
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
) = RustMatrixClientFactory(
baseDirectory = baseDirectory,

View file

@ -5,6 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
@ -12,17 +14,24 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.UserProfile
import java.io.File
class RustMatrixClientTest {
@ -51,9 +60,46 @@ class RustMatrixClientTest {
client.destroy()
}
@Test
fun `retrieving the UserProfile updates the database`() = runTest {
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
userDisplayName = null,
userAvatarUrl = null,
)
),
updateUserProfileResult = updateUserProfileResult,
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId ->
UserProfile(
userId = userId,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
)
},
),
sessionStore = sessionStore,
)
advanceUntilIdle()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
)
client.destroy()
}
private fun TestScope.createRustMatrixClient(
client: Client = FakeFfiClient(),
sessionStore: SessionStore = InMemorySessionStore(),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
) = RustMatrixClient(
innerClient = client,
baseDirectory = File(""),

View file

@ -42,6 +42,7 @@ class FakeFfiClient(
private val session: Session = aRustSession(),
private val clearCachesResult: () -> Unit = { lambdaError() },
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) },
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val closeResult: () -> Unit = {},
) : Client(NoPointer) {
@ -79,7 +80,7 @@ class FakeFfiClient(
}
override suspend fun getProfile(userId: String): UserProfile {
return UserProfile(userId = userId, displayName = null, avatarUrl = null)
return getProfileResult(userId)
}
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {

View file

@ -42,6 +42,5 @@ class FakeFfiClientBuilder(
override fun username(username: String) = this
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
override suspend fun build() = buildResult()
}

View file

@ -15,55 +15,55 @@ import org.matrix.rustcomponents.sdk.MessageLikeEventType
class MessageEventTypeKtTest {
@Test
fun `map Rust type should result to correct Kotlin type`() {
assertThat(MessageLikeEventType.CALL_ANSWER.map()).isEqualTo(MessageEventType.CALL_ANSWER)
assertThat(MessageLikeEventType.CALL_INVITE.map()).isEqualTo(MessageEventType.CALL_INVITE)
assertThat(MessageLikeEventType.CALL_HANGUP.map()).isEqualTo(MessageEventType.CALL_HANGUP)
assertThat(MessageLikeEventType.CALL_CANDIDATES.map()).isEqualTo(MessageEventType.CALL_CANDIDATES)
assertThat(MessageLikeEventType.RTC_NOTIFICATION.map()).isEqualTo(MessageEventType.RTC_NOTIFICATION)
assertThat(MessageLikeEventType.KEY_VERIFICATION_READY.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_READY)
assertThat(MessageLikeEventType.KEY_VERIFICATION_START.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_START)
assertThat(MessageLikeEventType.KEY_VERIFICATION_CANCEL.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_CANCEL)
assertThat(MessageLikeEventType.KEY_VERIFICATION_ACCEPT.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_ACCEPT)
assertThat(MessageLikeEventType.KEY_VERIFICATION_KEY.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_KEY)
assertThat(MessageLikeEventType.KEY_VERIFICATION_MAC.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_MAC)
assertThat(MessageLikeEventType.KEY_VERIFICATION_DONE.map()).isEqualTo(MessageEventType.KEY_VERIFICATION_DONE)
assertThat(MessageLikeEventType.REACTION.map()).isEqualTo(MessageEventType.REACTION)
assertThat(MessageLikeEventType.ROOM_ENCRYPTED.map()).isEqualTo(MessageEventType.ROOM_ENCRYPTED)
assertThat(MessageLikeEventType.ROOM_MESSAGE.map()).isEqualTo(MessageEventType.ROOM_MESSAGE)
assertThat(MessageLikeEventType.ROOM_REDACTION.map()).isEqualTo(MessageEventType.ROOM_REDACTION)
assertThat(MessageLikeEventType.STICKER.map()).isEqualTo(MessageEventType.STICKER)
assertThat(MessageLikeEventType.POLL_END.map()).isEqualTo(MessageEventType.POLL_END)
assertThat(MessageLikeEventType.POLL_RESPONSE.map()).isEqualTo(MessageEventType.POLL_RESPONSE)
assertThat(MessageLikeEventType.POLL_START.map()).isEqualTo(MessageEventType.POLL_START)
assertThat(MessageLikeEventType.UNSTABLE_POLL_END.map()).isEqualTo(MessageEventType.UNSTABLE_POLL_END)
assertThat(MessageLikeEventType.UNSTABLE_POLL_RESPONSE.map()).isEqualTo(MessageEventType.UNSTABLE_POLL_RESPONSE)
assertThat(MessageLikeEventType.UNSTABLE_POLL_START.map()).isEqualTo(MessageEventType.UNSTABLE_POLL_START)
assertThat(MessageLikeEventType.CallAnswer.map()).isEqualTo(MessageEventType.CallAnswer)
assertThat(MessageLikeEventType.CallInvite.map()).isEqualTo(MessageEventType.CallInvite)
assertThat(MessageLikeEventType.CallHangup.map()).isEqualTo(MessageEventType.CallHangup)
assertThat(MessageLikeEventType.CallCandidates.map()).isEqualTo(MessageEventType.CallCandidates)
assertThat(MessageLikeEventType.RtcNotification.map()).isEqualTo(MessageEventType.RtcNotification)
assertThat(MessageLikeEventType.KeyVerificationReady.map()).isEqualTo(MessageEventType.KeyVerificationReady)
assertThat(MessageLikeEventType.KeyVerificationStart.map()).isEqualTo(MessageEventType.KeyVerificationStart)
assertThat(MessageLikeEventType.KeyVerificationCancel.map()).isEqualTo(MessageEventType.KeyVerificationCancel)
assertThat(MessageLikeEventType.KeyVerificationAccept.map()).isEqualTo(MessageEventType.KeyVerificationAccept)
assertThat(MessageLikeEventType.KeyVerificationKey.map()).isEqualTo(MessageEventType.KeyVerificationKey)
assertThat(MessageLikeEventType.KeyVerificationMac.map()).isEqualTo(MessageEventType.KeyVerificationMac)
assertThat(MessageLikeEventType.KeyVerificationDone.map()).isEqualTo(MessageEventType.KeyVerificationDone)
assertThat(MessageLikeEventType.Reaction.map()).isEqualTo(MessageEventType.Reaction)
assertThat(MessageLikeEventType.RoomEncrypted.map()).isEqualTo(MessageEventType.RoomEncrypted)
assertThat(MessageLikeEventType.RoomMessage.map()).isEqualTo(MessageEventType.RoomMessage)
assertThat(MessageLikeEventType.RoomRedaction.map()).isEqualTo(MessageEventType.RoomRedaction)
assertThat(MessageLikeEventType.Sticker.map()).isEqualTo(MessageEventType.Sticker)
assertThat(MessageLikeEventType.PollEnd.map()).isEqualTo(MessageEventType.PollEnd)
assertThat(MessageLikeEventType.PollResponse.map()).isEqualTo(MessageEventType.PollResponse)
assertThat(MessageLikeEventType.PollStart.map()).isEqualTo(MessageEventType.PollStart)
assertThat(MessageLikeEventType.UnstablePollEnd.map()).isEqualTo(MessageEventType.UnstablePollEnd)
assertThat(MessageLikeEventType.UnstablePollResponse.map()).isEqualTo(MessageEventType.UnstablePollResponse)
assertThat(MessageLikeEventType.UnstablePollStart.map()).isEqualTo(MessageEventType.UnstablePollStart)
}
@Test
fun `map Kotlin type should result to correct Rust type`() {
assertThat(MessageEventType.CALL_ANSWER.map()).isEqualTo(MessageLikeEventType.CALL_ANSWER)
assertThat(MessageEventType.CALL_INVITE.map()).isEqualTo(MessageLikeEventType.CALL_INVITE)
assertThat(MessageEventType.CALL_HANGUP.map()).isEqualTo(MessageLikeEventType.CALL_HANGUP)
assertThat(MessageEventType.CALL_CANDIDATES.map()).isEqualTo(MessageLikeEventType.CALL_CANDIDATES)
assertThat(MessageEventType.RTC_NOTIFICATION.map()).isEqualTo(MessageLikeEventType.RTC_NOTIFICATION)
assertThat(MessageEventType.KEY_VERIFICATION_READY.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_READY)
assertThat(MessageEventType.KEY_VERIFICATION_START.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_START)
assertThat(MessageEventType.KEY_VERIFICATION_CANCEL.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_CANCEL)
assertThat(MessageEventType.KEY_VERIFICATION_ACCEPT.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_ACCEPT)
assertThat(MessageEventType.KEY_VERIFICATION_KEY.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_KEY)
assertThat(MessageEventType.KEY_VERIFICATION_MAC.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_MAC)
assertThat(MessageEventType.KEY_VERIFICATION_DONE.map()).isEqualTo(MessageLikeEventType.KEY_VERIFICATION_DONE)
assertThat(MessageEventType.REACTION.map()).isEqualTo(MessageLikeEventType.REACTION)
assertThat(MessageEventType.ROOM_ENCRYPTED.map()).isEqualTo(MessageLikeEventType.ROOM_ENCRYPTED)
assertThat(MessageEventType.ROOM_MESSAGE.map()).isEqualTo(MessageLikeEventType.ROOM_MESSAGE)
assertThat(MessageEventType.ROOM_REDACTION.map()).isEqualTo(MessageLikeEventType.ROOM_REDACTION)
assertThat(MessageEventType.STICKER.map()).isEqualTo(MessageLikeEventType.STICKER)
assertThat(MessageEventType.POLL_END.map()).isEqualTo(MessageLikeEventType.POLL_END)
assertThat(MessageEventType.POLL_RESPONSE.map()).isEqualTo(MessageLikeEventType.POLL_RESPONSE)
assertThat(MessageEventType.POLL_START.map()).isEqualTo(MessageLikeEventType.POLL_START)
assertThat(MessageEventType.UNSTABLE_POLL_END.map()).isEqualTo(MessageLikeEventType.UNSTABLE_POLL_END)
assertThat(MessageEventType.UNSTABLE_POLL_RESPONSE.map()).isEqualTo(MessageLikeEventType.UNSTABLE_POLL_RESPONSE)
assertThat(MessageEventType.UNSTABLE_POLL_START.map()).isEqualTo(MessageLikeEventType.UNSTABLE_POLL_START)
assertThat(MessageEventType.CallAnswer.map()).isEqualTo(MessageLikeEventType.CallAnswer)
assertThat(MessageEventType.CallInvite.map()).isEqualTo(MessageLikeEventType.CallInvite)
assertThat(MessageEventType.CallHangup.map()).isEqualTo(MessageLikeEventType.CallHangup)
assertThat(MessageEventType.CallCandidates.map()).isEqualTo(MessageLikeEventType.CallCandidates)
assertThat(MessageEventType.RtcNotification.map()).isEqualTo(MessageLikeEventType.RtcNotification)
assertThat(MessageEventType.KeyVerificationReady.map()).isEqualTo(MessageLikeEventType.KeyVerificationReady)
assertThat(MessageEventType.KeyVerificationStart.map()).isEqualTo(MessageLikeEventType.KeyVerificationStart)
assertThat(MessageEventType.KeyVerificationCancel.map()).isEqualTo(MessageLikeEventType.KeyVerificationCancel)
assertThat(MessageEventType.KeyVerificationAccept.map()).isEqualTo(MessageLikeEventType.KeyVerificationAccept)
assertThat(MessageEventType.KeyVerificationKey.map()).isEqualTo(MessageLikeEventType.KeyVerificationKey)
assertThat(MessageEventType.KeyVerificationMac.map()).isEqualTo(MessageLikeEventType.KeyVerificationMac)
assertThat(MessageEventType.KeyVerificationDone.map()).isEqualTo(MessageLikeEventType.KeyVerificationDone)
assertThat(MessageEventType.Reaction.map()).isEqualTo(MessageLikeEventType.Reaction)
assertThat(MessageEventType.RoomEncrypted.map()).isEqualTo(MessageLikeEventType.RoomEncrypted)
assertThat(MessageEventType.RoomMessage.map()).isEqualTo(MessageLikeEventType.RoomMessage)
assertThat(MessageEventType.RoomRedaction.map()).isEqualTo(MessageLikeEventType.RoomRedaction)
assertThat(MessageEventType.Sticker.map()).isEqualTo(MessageLikeEventType.Sticker)
assertThat(MessageEventType.PollEnd.map()).isEqualTo(MessageLikeEventType.PollEnd)
assertThat(MessageEventType.PollResponse.map()).isEqualTo(MessageLikeEventType.PollResponse)
assertThat(MessageEventType.PollStart.map()).isEqualTo(MessageLikeEventType.PollStart)
assertThat(MessageEventType.UnstablePollEnd.map()).isEqualTo(MessageLikeEventType.UnstablePollEnd)
assertThat(MessageEventType.UnstablePollResponse.map()).isEqualTo(MessageLikeEventType.UnstablePollResponse)
assertThat(MessageEventType.UnstablePollStart.map()).isEqualTo(MessageLikeEventType.UnstablePollStart)
}
}

View file

@ -57,6 +57,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT)
}
@ -77,6 +78,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED)
}
@ -97,6 +99,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED)
}

View file

@ -97,6 +97,8 @@ class FakeMatrixClient(
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
private val addRecentEmojiLambda: (String) -> Result<Unit> = { Result.success(Unit) },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@ -349,4 +351,12 @@ class FakeMatrixClient(
override suspend fun getMaxFileUploadSize(): Result<Long> {
return getMaxUploadSizeResult()
}
override suspend fun addRecentEmoji(emoji: String): Result<Unit> {
return addRecentEmojiLambda(emoji)
}
override suspend fun getRecentEmojis(): Result<List<String>> {
return getRecentEmojisLambda()
}
}

View file

@ -65,6 +65,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
const val A_CAPTION = "A media caption"
const val A_REASON = "A reason"
const val A_SPACE_NAME = "A space name"
const val A_REDACTION_REASON = "A redaction reason"
const val A_HOMESERVER_URL = "matrix.org"

View file

@ -10,7 +10,7 @@ package io.element.android.libraries.mediaupload.api
import android.net.Uri
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.flatMapCatching
@ -25,7 +25,7 @@ import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
@Inject
@AssistedInject
class MediaSender(
private val preProcessor: MediaPreProcessor,
private val room: JoinedRoom,

View file

@ -15,7 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
@ -24,7 +24,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresent
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class MediaGalleryNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -44,7 +44,7 @@ import io.element.android.libraries.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class MediaGalleryPresenter(
@Assisted private val navigator: MediaGalleryNavigator,
private val room: BaseRoom,

View file

@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
@ -40,7 +40,7 @@ import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class MediaGalleryFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -10,10 +10,10 @@ package io.element.android.libraries.mediaviewer.impl.gallery.voice
import androidx.compose.runtime.Composable
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.IntoMap
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
@ -33,7 +33,7 @@ interface VoiceMessagePresenterModule {
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): MediaItemPresenterFactory<*, *>
}
@Inject
@AssistedInject
class VoiceMessagePresenter(
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
@Assisted private val item: MediaItem.Voice,

View file

@ -14,7 +14,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.viewfolder.api.TextFileViewer
@ -33,7 +33,7 @@ import io.element.android.libraries.mediaviewer.impl.model.hasEvent
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesNode(RoomScope::class)
@Inject
@AssistedInject
class MediaViewerNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -23,7 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
@Inject
@AssistedInject
class MediaViewerPresenter(
@Assisted private val inputs: MediaViewerEntryPoint.Params,
@Assisted private val navigator: MediaViewerNavigator,

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_list_loading_media">"Зареждане на медийни файлове…"</string>
<string name="screen_media_browser_list_mode_files">"Файлове"</string>
<string name="screen_media_browser_list_mode_media">"Медия"</string>
<string name="screen_media_browser_title">"Медия и файлове"</string>

View file

@ -8,6 +8,6 @@
package io.element.android.libraries.oidc.api
sealed interface OidcAction {
data object GoBack : OidcAction
data class GoBack(val toUnblock: Boolean = false) : OidcAction
data class Success(val url: String) : OidcAction
}

View file

@ -36,7 +36,7 @@ class DefaultOidcUrlParser(
*/
override fun parse(url: String): OidcAction? {
if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null
if (url.contains("error=access_denied")) return OidcAction.GoBack
if (url.contains("error=access_denied")) return OidcAction.GoBack()
if (url.contains("code=")) return OidcAction.Success(url)
// Other case not supported, let's crash the app for now

View file

@ -24,10 +24,10 @@ class DefaultOidcActionFlowTest {
data.add(action)
}
}
sut.post(OidcAction.GoBack)
sut.post(OidcAction.GoBack())
delay(1)
sut.reset()
delay(1)
assertThat(data).containsExactly(OidcAction.GoBack, null)
assertThat(data).containsExactly(OidcAction.GoBack(), null)
}
}

View file

@ -29,7 +29,7 @@ class DefaultOidcIntentResolverTest {
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(OidcAction.GoBack)
assertThat(result).isEqualTo(OidcAction.GoBack())
}
@Test

View file

@ -31,7 +31,7 @@ class DefaultOidcUrlParserTest {
fun `test cancel url`() {
val sut = createDefaultOidcUrlParser()
val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO"
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack())
}
@Test

View file

@ -24,8 +24,8 @@ import com.google.accompanist.permissions.shouldShowRationale
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
@ -37,7 +37,7 @@ import timber.log.Timber
private val loggerTag = LoggerTag("DefaultPermissionsPresenter")
@Inject
@AssistedInject
class DefaultPermissionsPresenter(
@Assisted val permission: String,
private val permissionsStore: PermissionsStore,

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_check_permission_description">"Проверка дали приложението може да показва известия."</string>
<string name="troubleshoot_notifications_test_check_permission_title">"Проверка на разрешенията"</string>
</resources>

View file

@ -10,8 +10,8 @@ package io.element.android.libraries.push.impl.notifications
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.matrix.api.MatrixClient
@ -58,7 +58,7 @@ interface NotificationMediaRepo {
): Result<File>
}
@Inject
@AssistedInject
class DefaultNotificationMediaRepo(
@CacheDirectory private val cacheDir: File,
private val mxcTools: MxcTools,

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Обаждане"</string>
<string name="notification_channel_listening_for_events">"Слушане за събития"</string>
<string name="notification_channel_noisy">"Шумни известия"</string>
<string name="notification_channel_silent">"Безшумни известия"</string>
<plurals name="notification_compat_summary_line_for_room">
@ -10,12 +12,14 @@
<item quantity="one">"%d известие"</item>
<item quantity="other">"%d известия"</item>
</plurals>
<string name="notification_fallback_content">"Имате нови съобщения."</string>
<string name="notification_inline_reply_failed">"** Неуспешно изпращане - моля, отворете стаята"</string>
<string name="notification_invitation_action_join">"Присъединяване"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d покана"</item>
<item quantity="other">"%d покани"</item>
</plurals>
<string name="notification_invite_body">"Поканиха ви за чат"</string>
<string name="notification_mentioned_you_body">"Ви спомена: %1$s"</string>
<string name="notification_new_messages">"Нови съобщения"</string>
<plurals name="notification_new_messages_for_room">
@ -26,8 +30,14 @@
<string name="notification_room_action_mark_as_read">"Отбелязване като прочетено"</string>
<string name="notification_room_action_quick_reply">"Бърз отговор"</string>
<string name="notification_room_invite_body">"Ви покани да се присъедините към стаята"</string>
<string name="notification_sender_me">"Аз"</string>
<string name="notification_test_push_notification_content">"Преглеждате известието! Кликнете върху мен!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d непрочетено известено съобщение"</item>
<item quantity="other">"%d непрочетени известени съобщения"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s и %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s в %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s в %2$s и %3$s"</string>
@ -35,5 +45,20 @@
<item quantity="one">"%d стая"</item>
<item quantity="other">"%d стаи"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Синхронизация на заден план"</string>
<string name="push_distributor_firebase_android">"Услуги на Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Не са намерени валидни услуги на Google Play. Известията може да не работят правилно."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Проверка на блокирани потребители"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Преглед на блокираните потребители"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Няма блокирани потребители."</string>
<string name="troubleshoot_notifications_test_blocked_users_title">"Блокирани потребители"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Получаване на името на текущия доставчик."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Приложението е изградено с поддръжка за: %1$s"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Проверка дали приложението може да показва известия."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Известието не е било кликнато."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Не може да се покаже известието."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Известието беше натиснато!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Показване на известие"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Моля, натиснете известието, за да продължите теста."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Грешка: %1$s"</string>
</resources>

View file

@ -60,6 +60,15 @@
<string name="push_distributor_background_sync_android">"Synchronizace na pozadí"</string>
<string name="push_distributor_firebase_android">"Služby Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Kontrola blokovaných uživatelů"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Zobrazit blokované uživatele"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Žádní uživatelé nejsou blokováni."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Zablokovali jste %1$d uživatele. Nebudete dostávat oznámení od tohoto uživatele."</item>
<item quantity="few">"Zablokovali jste %1$d uživatele. Nebudete dostávat oznámení od těchto uživatelů."</item>
<item quantity="other">"Zablokovali jste %1$d uživatelů. Nebudete dostávat oznámení od těchto uživatelů."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Blokovaní uživatelé"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Získat název aktuálního poskytovatele."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Nebyli vybráni žádní push poskytovatelé."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Aktuální push poskytovatel: %1$s."</string>

View file

@ -54,6 +54,14 @@
<string name="push_distributor_background_sync_android">"Synkronisering i baggrunden"</string>
<string name="push_distributor_firebase_android">"Google-tjenester"</string>
<string name="push_no_valid_google_play_services_apk_android">"Der blev ikke fundet nogen gyldige Google Play-tjenester. Notifikationer fungerer muligvis ikke korrekt."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Kontrollerer blokerede brugere"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Se blokerede brugere"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Ingen brugere er blokeret."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Du har blokeret %1$d bruger. Du vil ikke modtage meddelelser fra denne bruger."</item>
<item quantity="other">"Du har blokeret %1$d brugere. Du vil ikke modtage meddelelser fra disse brugere."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Blokerede brugere"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Få navnet på den aktuelle udbyder."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Ingen push-udbydere valgt."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Nuværende push-udbyder: %1$s."</string>

View file

@ -54,6 +54,14 @@
<string name="push_distributor_background_sync_android">"Hintergrundsynchronisation"</string>
<string name="push_distributor_firebase_android">"Google-Dienste"</string>
<string name="push_no_valid_google_play_services_apk_android">"Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Überprüfen von gesperrten Nutzern"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Gesperrte Nutzer ansehen"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Keine Nutzer sind gesperrt."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Du hast %1$d Nutzer gesperrt. Du wirst für diesen Nutzer keine Benachrichtigungen erhalten."</item>
<item quantity="other">"Du hast %1$d Nutzer gesperrt. Du wirst für diese Nutzer keine Benachrichtigungen erhalten."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Gesperrte Nutzer"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Ermittele den Namen des aktuellen Anbieters."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Kein Dienst für Push-Benachrichtigungen ausgewählt."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Aktueller Push-Dienst: %1$s."</string>

View file

@ -54,6 +54,14 @@
<string name="push_distributor_background_sync_android">"Sünkroniseerimine taustal"</string>
<string name="push_distributor_firebase_android">"Google\'i teenused"</string>
<string name="push_no_valid_google_play_services_apk_android">"Google Play Services taustateenust ei leidu. Teavitused ei pruugi toimida korrektselt."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Kontrollin blokeeritud kasutajaid"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Vaata blokeeritud kasutajaid"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Sa pole ühtegi kasutajat blokeerinud."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Sa oled blokeerinud %1$d kasutaja. Sa ei saa tema kohta teavitusi"</item>
<item quantity="other">"Sa oled blokeerinud %1$d kasutajat. Sa ei saa tema kohta teavitusi"</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Blokeeritud kasutajad"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Vali hetkel kasutatava tõuketeenuste pakkuja nimi."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Tõuketeenuste pakkujaid pole valitud."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Hetkel kasutatav tõuketeenuste pakkuja: %1$s."</string>

View file

@ -54,6 +54,14 @@
<string name="push_distributor_background_sync_android">"Synchronisation en arrière-plan"</string>
<string name="push_distributor_firebase_android">"Services Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Aucun service Google Play valide na été trouvé. Les notifications peuvent ne pas fonctionner correctement."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Vérification des utilisateurs bloqués"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Voir les utilisateurs bloqués"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Aucun utilisateur nest bloqué."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Vous avez bloqué %1$d utilisateur. Vous ne recevrez pas de notifications pour cet utilisateur."</item>
<item quantity="other">"Vous avez bloqué %1$d utilisateurs. Vous ne recevrez pas de notifications pour ces utilisateurs."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Utilisateurs bloqués"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Obtenir le nom du fournisseur de Push actuel."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Aucun fournisseur de Push nest sélectionné."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Fournisseur de Push actuel : %1$s."</string>

View file

@ -54,6 +54,14 @@
<string name="push_distributor_background_sync_android">"Háttérszinkronizálás"</string>
<string name="push_distributor_firebase_android">"Google szolgáltatások"</string>
<string name="push_no_valid_google_play_services_apk_android">"A Google Play szolgáltatások nem találhatók. Előfordulhat, hogy az értesítések nem működnek megfelelően."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Letiltott felhasználók ellenőrzése"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Letiltott felhasználók megtekintése"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Nincs felhasználó letiltva."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Letiltotta %1$d felhasználót. Nem fog értesítéseket kapni erről a felhasználóról."</item>
<item quantity="other">"Letiltott %1$d felhasználót. Nem fog értesítéseket kapni ezekről a felhasználókról."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Letiltott felhasználók"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"A jelenlegi szolgáltató nevének lekérdezése."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Nincs kiválasztva leküldéses értesítési szolgáltató."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Jelenlegi leküldéses értesítési szolgáltató: %1$s."</string>
@ -61,8 +69,8 @@
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Győződjön meg arról, hogy az alkalmazás legalább egy leküldéses értesítési szolgáltatóval rendelkezik."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Nem található leküldéses értesítési szolgáltató."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"%1$d leküldéses szolgáltató találva: %2$s"</item>
<item quantity="other">"%1$d leküldéses szolgáltató találva: %2$s"</item>
<item quantity="one">"%1$d leküldéses értesítési szolgáltató találva: %2$s"</item>
<item quantity="other">"%1$d leküldéses értesítési szolgáltató találva: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Az alkalmazás úgy lett összeállítva, hogy támogatja a következőket: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Leküldéses értesítési szolgáltatók észlelése"</string>
@ -78,5 +86,5 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Hiba, nem lehet tesztelni a leküldéses értesítést."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Hiba, időtúllépés a leküldéses értesítésre való várakozás során."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"A leküldéses értesítés folyamata %1$d ezredmásodpercig tartott."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Tesztelje a leküldéses értesítés folyamatát"</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Leküldéses értesítés folyamatának tesztelése"</string>
</resources>

View file

@ -54,6 +54,14 @@
<string name="push_distributor_background_sync_android">"Sincronização em segundo plano"</string>
<string name="push_distributor_firebase_android">"Serviços do Google Play"</string>
<string name="push_no_valid_google_play_services_apk_android">"Nenhuns Serviços do Google Play válidos encontrados. As notificações poderão não funcionar devidamente."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"A verificar utilizadores bloqueados"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Ver utilizadores bloqueados"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Sem utilizadores bloqueados."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
<item quantity="one">"Bloqueaste %1$d utilizador. Não receberás notificações dele."</item>
<item quantity="other">"Bloqueaste %1$d utilizadores. Não receberás notificações deles."</item>
</plurals>
<string name="troubleshoot_notifications_test_blocked_users_title">"Utilizadores bloqueados"</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Obtém o nome do fornecedor atual."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Nenhum fornecedor de envio selecionado."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Fornecedor de envio atual: %1$s."</string>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_firebase_availability_description">"Уверете се, че Firebase е наличен."</string>
<string name="troubleshoot_notifications_test_firebase_availability_failure">"Firebase не е наличен."</string>
<string name="troubleshoot_notifications_test_firebase_availability_success">"Firebase е наличен."</string>
<string name="troubleshoot_notifications_test_firebase_availability_title">"Проверка на Firebase"</string>
</resources>

View file

@ -13,7 +13,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ -23,7 +23,7 @@ import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class RoomSelectNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@ -27,7 +27,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Inject
@AssistedInject
class RoomSelectPresenter(
@Assisted private val mode: RoomSelectMode,
private val dataSource: RoomSelectSearchDataSource,

View file

@ -39,4 +39,12 @@ data class SessionData(
val sessionPath: String,
/** The path to the cache data stored for the session in the filesystem. */
val cachePath: String,
/** The position, to be able to order account. */
val position: Long,
/** The index of the last date of session usage. */
val lastUsageIndex: Long,
/** The optional display name of the user. */
val userDisplayName: String?,
/** The optional avatar URL of the user. */
val userAvatarUrl: String?,
)

View file

@ -11,8 +11,22 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface SessionStore {
/**
* A flow emitting the current logged in state.
* If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session.
* If there is no session, the state is [LoggedInState.NotLoggedIn].
*/
fun loggedInStateFlow(): Flow<LoggedInState>
/**
* Return a flow of all sessions ordered by last usage descending.
*/
fun sessionsFlow(): Flow<List<SessionData>>
/**
* Add a new session. If other sessions exist, the new one will be set as the latest used one, and
* the added session position will be set to a value higher than the other session positions.
*/
suspend fun addSession(sessionData: SessionData)
/**
@ -20,9 +34,35 @@ interface SessionStore {
* No op if userId is not found in DB.
*/
suspend fun updateData(sessionData: SessionData)
/**
* Update the user profile info of the session matching the userId.
*/
suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?)
/**
* Get the session data matching the userId, or null if not found.
*/
suspend fun getSession(sessionId: String): SessionData?
/**
* Get all sessions ordered by last usage descending.
*/
suspend fun getAllSessions(): List<SessionData>
/**
* Get the latest session, or null if no session exists.
*/
suspend fun getLatestSession(): SessionData?
/**
* Set the session with [sessionId] as the latest used one.
*/
suspend fun setLatestSession(sessionId: String)
/**
* Remove the session matching the sessionId.
*/
suspend fun removeSession(sessionId: String)
}

View file

@ -36,7 +36,7 @@ dependencies {
sqldelight {
databases {
create("SessionDatabase") {
// https://cashapp.github.io/sqldelight/2.0.0/android_sqlite/migrations/
// https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/
// To generate a .db file from your latest schema, run this task
// ./gradlew generateDebugSessionDatabaseSchema
// Test migration by running

View file

@ -34,7 +34,7 @@ class DatabaseSessionStore(
private val sessionDataMutex = Mutex()
override fun loggedInStateFlow(): Flow<LoggedInState> {
return database.sessionDataQueries.selectFirst()
return database.sessionDataQueries.selectLatest()
.asFlow()
.mapToOneOrNull(dispatchers.io)
.map {
@ -51,7 +51,17 @@ class DatabaseSessionStore(
override suspend fun addSession(sessionData: SessionData) {
sessionDataMutex.withLock {
database.sessionDataQueries.insertSessionData(sessionData.toDbModel())
val lastUsageIndex = getLastUsageIndex()
database.sessionDataQueries.insertSessionData(
sessionData
.copy(
// position value does not really matter, so just use lastUsageIndex + 1 to ensure that
// the value is always greater than value of any existing account
position = lastUsageIndex + 1,
lastUsageIndex = lastUsageIndex + 1,
)
.toDbModel()
)
}
}
@ -65,18 +75,71 @@ class DatabaseSessionStore(
Timber.e("User ${sessionData.userId} not found in session database")
return
}
// Copy new data from SDK, but keep login timestamp
// Copy new data from SDK, but keep application data
database.sessionDataQueries.updateSession(
sessionData.copy(
loginTimestamp = result.loginTimestamp,
position = result.position,
lastUsageIndex = result.lastUsageIndex,
userDisplayName = result.userDisplayName,
userAvatarUrl = result.userAvatarUrl,
).toDbModel()
)
}
}
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
sessionDataMutex.withLock {
val result = database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
?.toApiModel()
if (result == null) {
Timber.e("User $sessionId not found in session database")
return
}
database.sessionDataQueries.updateSession(
result.copy(
userDisplayName = displayName,
userAvatarUrl = avatarUrl,
).toDbModel()
)
}
}
override suspend fun setLatestSession(sessionId: String) {
val latestSession = getLatestSession()
if (latestSession?.userId == sessionId) {
// Already the latest session
return
}
val lastUsageIndex = latestSession?.lastUsageIndex ?: 0
val result = database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
?.toApiModel()
if (result == null) {
Timber.e("User $sessionId not found in session database")
return
}
sessionDataMutex.withLock {
// Update lastUsageIndex of the session
database.sessionDataQueries.updateSession(
result.copy(
lastUsageIndex = lastUsageIndex + 1,
).toDbModel()
)
}
}
private fun getLastUsageIndex(): Long {
return database.sessionDataQueries.selectLatest()
.executeAsOneOrNull()
?.lastUsageIndex
?: -1L
}
override suspend fun getLatestSession(): SessionData? {
return sessionDataMutex.withLock {
database.sessionDataQueries.selectFirst()
database.sessionDataQueries.selectLatest()
.executeAsOneOrNull()
?.toApiModel()
}

View file

@ -27,6 +27,10 @@ internal fun SessionData.toDbModel(): DbSessionData {
passphrase = passphrase,
sessionPath = sessionPath,
cachePath = cachePath,
position = position,
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
)
}
@ -45,5 +49,9 @@ internal fun DbSessionData.toApiModel(): SessionData {
passphrase = passphrase,
sessionPath = sessionPath,
cachePath = cachePath,
position = position,
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
)
}

View file

@ -27,15 +27,25 @@ CREATE TABLE SessionData (
-- added in version 6
sessionPath TEXT NOT NULL DEFAULT "",
-- added in version 9
cachePath TEXT NOT NULL DEFAULT ""
cachePath TEXT NOT NULL DEFAULT "",
-- added in version 10
-- position, to be able to sort account by session creation date
position INTEGER NOT NULL DEFAULT 0,
-- index of the last usage session. Each time the current session change, the index of the current
-- session is incremented to the max value + 1 so it becomes the current session
lastUsageIndex INTEGER NOT NULL DEFAULT 0,
-- user display name
userDisplayName TEXT,
-- user avatar url
userAvatarUrl TEXT
);
selectFirst:
SELECT * FROM SessionData LIMIT 1;
selectLatest:
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1;
selectAll:
SELECT * FROM SessionData;
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC;
selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;

View file

@ -0,0 +1,9 @@
-- Migrate DB from version 9
-- Add position to be able to sort account by session creation date
-- Add lastUsageIndex so we can restore the last session and switch to another one
-- Add display name and avatar url of the user so that we can display a list of accounts.
ALTER TABLE SessionData ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
ALTER TABLE SessionData ADD COLUMN lastUsageIndex INTEGER NOT NULL DEFAULT 0;
ALTER TABLE SessionData ADD COLUMN userDisplayName TEXT;
ALTER TABLE SessionData ADD COLUMN userAvatarUrl TEXT;

View file

@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.session.SessionData
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.LoginType
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@ -45,11 +46,11 @@ class DatabaseSessionStoreTest {
@Test
fun `addSession persists the SessionData into the DB`() = runTest {
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull()
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull()
databaseSessionStore.addSession(aSessionData.toApiModel())
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
}
@ -59,7 +60,12 @@ class DatabaseSessionStoreTest {
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
databaseSessionStore.addSession(aSessionData.toApiModel())
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
// TODO add more sessions in multi-account PR.
// Add a second session
databaseSessionStore.addSession(aSessionData.copy(userId = "otherUserId").toApiModel())
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = "otherUserId", isTokenValid = true))
// Remove the second session
databaseSessionStore.removeSession("otherUserId")
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
// Remove the first session
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
@ -124,7 +130,83 @@ class DatabaseSessionStoreTest {
}
@Test
fun `update session update all fields except loginTimestamp`() = runTest {
fun `updateUserProfile does nothing if the session is not found`() = runTest {
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
}
@Test
fun `updateUserProfile update the data`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName")
assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl")
}
@Test
fun `setLatestSession is no op when the session is already the latest session`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
val session = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(session.lastUsageIndex).isEqualTo(0)
assertThat(session.position).isEqualTo(0)
databaseSessionStore.setLatestSession(aSessionData.userId)
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne().lastUsageIndex).isEqualTo(0)
}
@Test
fun `setLatestSession is no op when the session is not found`() = runTest {
databaseSessionStore.setLatestSession(aSessionData.userId)
}
@Test
fun `multi session test`() = runTest {
databaseSessionStore.addSession(aSessionData.toApiModel())
val session = databaseSessionStore.getSession(aSessionData.userId)!!
assertThat(session.lastUsageIndex).isEqualTo(0)
assertThat(session.position).isEqualTo(0)
val secondSessionData = aSessionData.copy(
userId = "otherUserId",
position = 1,
lastUsageIndex = 1,
)
databaseSessionStore.addSession(secondSessionData.toApiModel())
val secondSession = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
assertThat(secondSession.lastUsageIndex).isEqualTo(1)
assertThat(secondSession.position).isEqualTo(1)
// Set the first session as the latest
databaseSessionStore.setLatestSession(aSessionData.userId)
val firstSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(firstSession.lastUsageIndex).isEqualTo(2)
assertThat(firstSession.position).isEqualTo(0)
// Check that the second session has not been altered
val secondSession2 = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
assertThat(secondSession2.lastUsageIndex).isEqualTo(1)
assertThat(secondSession2.position).isEqualTo(1)
}
@Test
fun `test sessionsFlow()`() = runTest {
databaseSessionStore.sessionsFlow().test {
assertThat(awaitItem()).isEmpty()
databaseSessionStore.addSession(aSessionData.toApiModel())
assertThat(awaitItem().size).isEqualTo(1)
val secondSessionData = aSessionData.copy(
userId = "otherUserId",
position = 1,
lastUsageIndex = 1,
)
databaseSessionStore.addSession(secondSessionData.toApiModel())
assertThat(awaitItem().size).isEqualTo(2)
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(awaitItem().size).isEqualTo(1)
databaseSessionStore.removeSession(secondSessionData.userId)
assertThat(awaitItem()).isEmpty()
}
}
@Test
fun `update session update all fields except info used by the application`() = runTest {
val firstSessionData = SessionData(
userId = "userId",
deviceId = "deviceId",
@ -139,6 +221,10 @@ class DatabaseSessionStoreTest {
passphrase = "aPassphrase",
sessionPath = "sessionPath",
cachePath = "cachePath",
position = 0,
lastUsageIndex = 0,
userDisplayName = "userDisplayName",
userAvatarUrl = "userAvatarUrl",
)
val secondSessionData = SessionData(
userId = "userId",
@ -152,8 +238,12 @@ class DatabaseSessionStoreTest {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphraseAltered",
sessionPath = "sessionPath",
cachePath = "cachePath",
sessionPath = "sessionPathAltered",
cachePath = "cachePathAltered",
position = 1,
lastUsageIndex = 1,
userDisplayName = "userDisplayNameAltered",
userAvatarUrl = "userAvatarUrlAltered",
)
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
@ -174,6 +264,11 @@ class DatabaseSessionStoreTest {
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
// Check that application data have not been altered
assertThat(alteredSession.position).isEqualTo(firstSessionData.position)
assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex)
assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName)
assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl)
}
@Test
@ -188,10 +283,14 @@ class DatabaseSessionStoreTest {
loginTimestamp = 1,
oidcData = "aOidcData",
isTokenValid = 1,
loginType = null,
loginType = LoginType.PASSWORD.name,
passphrase = "aPassphrase",
sessionPath = "sessionPath",
cachePath = "cachePath",
position = 0,
lastUsageIndex = 0,
userDisplayName = "userDisplayName",
userAvatarUrl = "userAvatarUrl",
)
val secondSessionData = SessionData(
userId = "userIdUnknown",
@ -203,10 +302,14 @@ class DatabaseSessionStoreTest {
loginTimestamp = 2,
oidcData = "aOidcDataAltered",
isTokenValid = 1,
loginType = null,
loginType = LoginType.PASSWORD.name,
passphrase = "aPassphraseAltered",
sessionPath = "sessionPath",
cachePath = "cachePath",
sessionPath = "sessionPathAltered",
cachePath = "cachePathAltered",
position = 1,
lastUsageIndex = 1,
userDisplayName = "userDisplayNameAltered",
userAvatarUrl = "userAvatarUrlAltered",
)
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
@ -216,14 +319,6 @@ class DatabaseSessionStoreTest {
// Get the session and check that it has not been altered
val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel()
assertThat(notAlteredSession.userId).isEqualTo(firstSessionData.userId)
assertThat(notAlteredSession.deviceId).isEqualTo(firstSessionData.deviceId)
assertThat(notAlteredSession.accessToken).isEqualTo(firstSessionData.accessToken)
assertThat(notAlteredSession.refreshToken).isEqualTo(firstSessionData.refreshToken)
assertThat(notAlteredSession.homeserverUrl).isEqualTo(firstSessionData.homeserverUrl)
assertThat(notAlteredSession.slidingSyncProxy).isEqualTo(firstSessionData.slidingSyncProxy)
assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData)
assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase)
assertThat(notAlteredSession).isEqualTo(firstSessionData)
}
}

View file

@ -24,4 +24,8 @@ internal fun aSessionData() = SessionData(
passphrase = null,
sessionPath = "sessionPath",
cachePath = "cachePath",
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)

View file

@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.map
class InMemorySessionStore(
initialList: List<SessionData> = emptyList(),
private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") },
private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") },
) : SessionStore {
private val sessionDataListFlow = MutableStateFlow(initialList)
@ -53,6 +55,10 @@ class InMemorySessionStore(
}
}
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
updateUserProfileResult(sessionId, displayName, avatarUrl)
}
override suspend fun getSession(sessionId: String): SessionData? {
return sessionDataListFlow.value.firstOrNull { it.userId == sessionId }
}
@ -65,6 +71,10 @@ class InMemorySessionStore(
return sessionDataListFlow.value.firstOrNull()
}
override suspend fun setLatestSession(sessionId: String) {
setLatestSessionResult(sessionId)
}
override suspend fun removeSession(sessionId: String) {
val currentList = sessionDataListFlow.value.toMutableList()
currentList.removeAll { it.userId == sessionId }

View file

@ -18,7 +18,11 @@ fun aSessionData(
cachePath: String = "/a/path/to/a/cache",
accessToken: String = "anAccessToken",
refreshToken: String? = "aRefreshToken",
): SessionData {
position: Long = 0,
lastUsageIndex: Long = 0,
userDisplayName: String? = null,
userAvatarUrl: String? = null,
): SessionData {
return SessionData(
userId = sessionId,
deviceId = deviceId,
@ -33,5 +37,9 @@ fun aSessionData(
passphrase = null,
sessionPath = sessionPath,
cachePath = cachePath,
position = position,
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
)
}

View file

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Прикачване на файл"</string>
<string name="rich_text_editor_close_formatting_options">"Отказ и затваряне на форматирането на текст"</string>
<string name="rich_text_editor_code_block">"Превключване на кодов блок"</string>
<string name="rich_text_editor_composer_placeholder">"Съобщение…"</string>
<string name="rich_text_editor_create_link">"Създаване на връзка"</string>
<string name="rich_text_editor_edit_link">"Редактиране на връзката"</string>
@ -8,8 +10,15 @@
<string name="rich_text_editor_format_italic">"Прилагане на курсив формат"</string>
<string name="rich_text_editor_format_strikethrough">"Прилагане на зачеркнат формат"</string>
<string name="rich_text_editor_format_underline">"Прилагане на формат за подчертаване"</string>
<string name="rich_text_editor_full_screen_toggle">"Превключване на режим на цял екран"</string>
<string name="rich_text_editor_indent">"Отстъп навътре"</string>
<string name="rich_text_editor_inline_code">"Прилагане на формат на вграден код"</string>
<string name="rich_text_editor_link">"Задаване на връзка"</string>
<string name="rich_text_editor_numbered_list">"Превключване на номериран списък"</string>
<string name="rich_text_editor_open_compose_options">"Отваряне на опциите за съставяне"</string>
<string name="rich_text_editor_quote">"Превключване на цитат"</string>
<string name="rich_text_editor_remove_link">"Премахване на връзката"</string>
<string name="rich_text_editor_unindent">"Отстъп навън"</string>
<string name="rich_text_editor_url_placeholder">"Връзка"</string>
<string name="screen_room_voice_message_tooltip">"Задръжте, за записване"</string>
</resources>

View file

@ -13,7 +13,6 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface PushHistoryEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@ -25,6 +24,6 @@ interface PushHistoryEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId)
fun navigateTo(roomId: RoomId, eventId: EventId)
}
}

View file

@ -14,7 +14,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ -23,7 +23,7 @@ import io.element.android.libraries.troubleshoot.api.test.NotificationTroublesho
import io.element.android.services.analytics.api.ScreenTracker
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class TroubleshootNotificationsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,

View file

@ -14,12 +14,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class TroubleshootNotificationsPresenter(
@Assisted private val navigator: NotificationTroubleshootNavigator,
private val troubleshootTestSuite: TroubleshootTestSuite,

View file

@ -7,8 +7,13 @@
package io.element.android.libraries.troubleshoot.impl.history
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PushHistoryEvents {
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents
data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvents
data object ClearDialog : PushHistoryEvents
}

View file

@ -14,36 +14,37 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import io.element.android.services.analytics.api.ScreenTracker
@ContributesNode(SessionScope::class)
@Inject
@AssistedInject
class PushHistoryNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PushHistoryPresenter,
presenterFactory: PushHistoryPresenter.Factory,
private val screenTracker: ScreenTracker,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), PushHistoryNavigator {
private fun onDone() {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onDone()
}
}
private fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
override fun navigateTo(roomId: RoomId, eventId: EventId) {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onItemClick(sessionId, roomId, eventId)
it.navigateTo(roomId, eventId)
}
}
private val presenter = presenterFactory.create(this)
@Composable
override fun View(modifier: Modifier) {
screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot)
@ -51,7 +52,6 @@ class PushHistoryNode(
PushHistoryView(
state = state,
onBackClick = ::onDone,
onItemClick = ::onItemClick,
modifier = modifier,
)
}

View file

@ -14,18 +14,36 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.PushService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@Inject
fun interface PushHistoryNavigator {
fun navigateTo(roomId: RoomId, eventId: EventId)
}
@AssistedInject
class PushHistoryPresenter(
@Assisted private val pushHistoryNavigator: PushHistoryNavigator,
private val pushService: PushService,
matrixClient: MatrixClient,
) : Presenter<PushHistoryState> {
@AssistedFactory
fun interface Factory {
fun create(pushHistoryNavigator: PushHistoryNavigator): PushHistoryPresenter
}
private val sessionId = matrixClient.sessionId
@Composable
override fun present(): PushHistoryState {
val coroutineScope = rememberCoroutineScope()
@ -41,6 +59,7 @@ class PushHistoryPresenter(
}
}.collectAsState(emptyList())
var resetAction: AsyncAction<Unit> by remember { mutableStateOf(AsyncAction.Uninitialized) }
var showNotSameAccountError by remember { mutableStateOf(false) }
fun handleEvents(event: PushHistoryEvents) {
when (event) {
@ -60,6 +79,14 @@ class PushHistoryPresenter(
}
PushHistoryEvents.ClearDialog -> {
resetAction = AsyncAction.Uninitialized
showNotSameAccountError = false
}
is PushHistoryEvents.NavigateTo -> {
if (event.sessionId != sessionId) {
showNotSameAccountError = true
} else {
pushHistoryNavigator.navigateTo(event.roomId, event.eventId)
}
}
}
}
@ -69,6 +96,7 @@ class PushHistoryPresenter(
pushHistoryItems = pushHistory.toImmutableList(),
showOnlyErrors = showOnlyErrors,
resetAction = resetAction,
showNotSameAccountError = showNotSameAccountError,
eventSink = ::handleEvents
)
}

View file

@ -16,5 +16,6 @@ data class PushHistoryState(
val pushHistoryItems: ImmutableList<PushHistoryItem>,
val showOnlyErrors: Boolean,
val resetAction: AsyncAction<Unit>,
val showNotSameAccountError: Boolean,
val eventSink: (PushHistoryEvents) -> Unit,
)

View file

@ -40,6 +40,9 @@ open class PushHistoryStateProvider : PreviewParameterProvider<PushHistoryState>
aPushHistoryState(
resetAction = AsyncAction.ConfirmingNoParams,
),
aPushHistoryState(
showNotSameAccountError = true,
),
)
}
@ -48,12 +51,14 @@ fun aPushHistoryState(
pushHistoryItems: List<PushHistoryItem> = emptyList(),
showOnlyErrors: Boolean = false,
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
showNotSameAccountError: Boolean = false,
eventSink: (PushHistoryEvents) -> Unit = {},
) = PushHistoryState(
pushCounter = pushCounter,
pushHistoryItems = pushHistoryItems.toImmutableList(),
showOnlyErrors = showOnlyErrors,
resetAction = resetAction,
showNotSameAccountError = showNotSameAccountError,
eventSink = eventSink,
)

View file

@ -37,6 +37,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -48,9 +49,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.troubleshoot.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@ -60,7 +58,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PushHistoryView(
state: PushHistoryState,
onBackClick: () -> Unit,
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
@ -123,7 +120,6 @@ fun PushHistoryView(
.padding(padding)
.consumeWindowInsets(padding),
state = state,
onItemClick = onItemClick,
)
}
@ -142,12 +138,18 @@ fun PushHistoryView(
},
onErrorDismiss = {},
)
if (state.showNotSameAccountError) {
ErrorDialog(
content = "Please switch account first to navigate to the event.",
onSubmit = { state.eventSink(PushHistoryEvents.ClearDialog) }
)
}
}
@Composable
private fun PushHistoryContent(
state: PushHistoryState,
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -173,7 +175,7 @@ private fun PushHistoryContent(
val roomId = pushHistory.roomId
val eventId = pushHistory.eventId
if (sessionId != null && roomId != null && eventId != null) {
onItemClick(sessionId, roomId, eventId)
state.eventSink(PushHistoryEvents.NavigateTo(sessionId, roomId, eventId))
}
}
)
@ -271,6 +273,5 @@ internal fun PushHistoryViewPreview(
PushHistoryView(
state = state,
onBackClick = {},
onItemClick = { _, _, _ -> },
)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_screen_action">"Изпълняване на тестове"</string>
<string name="troubleshoot_notifications_screen_action_again">"Изпълняване на тестовете отново"</string>
<string name="troubleshoot_notifications_screen_success">"Всички тестове преминаха успешно."</string>
<string name="troubleshoot_notifications_screen_title">"Отстраняване на неизправности с известията"</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_push_history_title">"Leküldési értesítés előzmények"</string>
<string name="screen_push_history_title">"Leküldéses értesítések előzmények"</string>
<string name="troubleshoot_notifications_screen_action">"Tesztek futtatása"</string>
<string name="troubleshoot_notifications_screen_action_again">"Tesztek újbóli futtatása"</string>
<string name="troubleshoot_notifications_screen_failure">"Egyes tesztek sikertelenek voltak. Ellenőrizze a részleteket."</string>

View file

@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import io.element.android.services.analytics.test.FakeScreenTracker
@ -32,15 +32,21 @@ class DefaultPushHistoryEntryPointTest {
PushHistoryNode(
buildContext = buildContext,
plugins = plugins,
presenter = PushHistoryPresenter(
pushService = FakePushService(),
),
presenterFactory = {
PushHistoryPresenter(
pushHistoryNavigator = object : PushHistoryNavigator {
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
},
pushService = FakePushService(),
matrixClient = FakeMatrixClient(),
)
},
screenTracker = FakeScreenTracker(),
)
}
val callback = object : PushHistoryEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError()
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)

View file

@ -11,9 +11,19 @@ package io.element.android.libraries.troubleshoot.impl.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@ -29,6 +39,7 @@ class PushHistoryPresenterTest {
assertThat(initialState.pushHistoryItems).isEmpty()
assertThat(initialState.showOnlyErrors).isFalse()
assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.showNotSameAccountError).isFalse()
}
}
@ -119,11 +130,57 @@ class PushHistoryPresenterTest {
}
}
@Test
fun `present - item click current account`() = runTest {
val pushHistoryNavigatorResult = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
val presenter = createPushHistoryPresenter(
pushHistoryNavigator = { roomId, eventId ->
pushHistoryNavigatorResult(roomId, eventId)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(
PushHistoryEvents.NavigateTo(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
)
pushHistoryNavigatorResult.assertions()
.isCalledOnce()
.with(value(A_ROOM_ID), value(AN_EVENT_ID))
}
}
@Test
fun `present - item click not current account`() = runTest {
val presenter = createPushHistoryPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(
PushHistoryEvents.NavigateTo(
sessionId = A_SESSION_ID_2,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
)
assertThat(awaitItem().showNotSameAccountError).isTrue()
// Reset error
initialState.eventSink(PushHistoryEvents.ClearDialog)
assertThat(awaitItem().showNotSameAccountError).isFalse()
}
}
private fun createPushHistoryPresenter(
pushHistoryNavigator: PushHistoryNavigator = PushHistoryNavigator { _, _ -> lambdaError() },
pushService: PushService = FakePushService(),
matrixClient: MatrixClient = FakeMatrixClient(),
): PushHistoryPresenter {
return PushHistoryPresenter(
pushHistoryNavigator = pushHistoryNavigator,
pushService = pushService,
matrixClient = matrixClient,
)
}
}

View file

@ -14,20 +14,14 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_FORMATTED_DATE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -103,9 +97,8 @@ class PushHistoryViewTest {
}
@Test
fun `clicking on a valid event invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
val onItemClick = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
fun `clicking on a valid event emits the expected Event`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
rule.setPushHistoryView(
aPushHistoryState(
pushHistoryItems = listOf(
@ -118,25 +111,26 @@ class PushHistoryViewTest {
),
eventSink = eventsRecorder,
),
onItemClick = onItemClick,
)
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
onItemClick.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
eventsRecorder.assertSingle(
PushHistoryEvents.NavigateTo(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPushHistoryView(
state: PushHistoryState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onItemClick: (SessionId, RoomId, EventId) -> Unit = EnsureNeverCalledWithThreeParams(),
) {
setContent {
PushHistoryView(
state = state,
onBackClick = onBackClick,
onItemClick = onItemClick,
)
}
}

View file

@ -1,14 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_add_reaction">"Добавяне на реакция: %1$s"</string>
<string name="a11y_delete">"Изтриване"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d въведена цифра"</item>
<item quantity="other">"%1$d въведени цифри"</item>
</plurals>
<string name="a11y_edit_room_address_hint">"Пълният адрес ще бъде %1$s"</string>
<string name="a11y_encryption_details">"Подробности за шифроването"</string>
<string name="a11y_hide_password">"Скриване на паролата"</string>
<string name="a11y_join_call">"Присъединяване към обаждане"</string>
<string name="a11y_jump_to_bottom">"Скок към най-долу"</string>
<string name="a11y_notifications_mentions_only">"Само споменавания"</string>
<string name="a11y_notifications_muted">"Заглушено"</string>
<string name="a11y_notifications_new_mentions">"Нови споменавания"</string>
<string name="a11y_notifications_new_messages">"Нови съобщения"</string>
<string name="a11y_notifications_ongoing_call">"Текущо обаждане"</string>
<string name="a11y_page_n">"Страница %1$d"</string>
<string name="a11y_pause">"Пауза"</string>
<string name="a11y_pin_field">"PIN поле"</string>
@ -26,16 +33,20 @@
<string name="a11y_remove_reaction">"Премахване на реакция с %1$s"</string>
<string name="a11y_send_files">"Изпращане на файлове"</string>
<string name="a11y_show_password">"Показване на паролата"</string>
<string name="a11y_start_call">"Започнете обаждане"</string>
<string name="a11y_user_menu">"Потребителско меню"</string>
<string name="a11y_view_details">"Вижте подробности"</string>
<string name="action_accept">"Приемане"</string>
<string name="action_add_to_timeline">"Добавяне към хронологията"</string>
<string name="action_back">"Назад"</string>
<string name="action_call">"Обаждане"</string>
<string name="action_cancel">"Отказ"</string>
<string name="action_choose_photo">"Избор на снимка"</string>
<string name="action_clear">"Изчистване"</string>
<string name="action_close">"Затваряне"</string>
<string name="action_complete_verification">"Завършване на потвърждаването"</string>
<string name="action_confirm">"Потвърждаване"</string>
<string name="action_confirm_password">"Потвърдете паролата"</string>
<string name="action_continue">"Продължаване"</string>
<string name="action_copy">"Копиране"</string>
<string name="action_copy_link">"Копиране на връзката"</string>
@ -68,6 +79,7 @@
<string name="action_leave">"Напускане"</string>
<string name="action_leave_conversation">"Напускане на разговора"</string>
<string name="action_leave_room">"Напускане на стаята"</string>
<string name="action_leave_space">"Напускане на пространството"</string>
<string name="action_load_more">"Зареждане на още"</string>
<string name="action_manage_account">"Управление на профила"</string>
<string name="action_manage_devices">"Управление на устройствата"</string>
@ -85,7 +97,11 @@
<string name="action_remove_message">"Премахване на съобщението"</string>
<string name="action_reply">"Отговор"</string>
<string name="action_reply_in_thread">"Отговор в нишка"</string>
<string name="action_report">"Докладване"</string>
<string name="action_report_bug">"Докладване на грешка"</string>
<string name="action_report_content">"Докладване на съдържанието"</string>
<string name="action_report_room">"Докладване на стаята"</string>
<string name="action_reset">"Нулиране"</string>
<string name="action_retry">"Повторен опит"</string>
<string name="action_retry_decryption">"Повторен опит за разшифроване"</string>
<string name="action_save">"Запазване"</string>
@ -102,25 +118,35 @@
<string name="action_start">"Започване"</string>
<string name="action_start_chat">"Започване на чат"</string>
<string name="action_start_verification">"Започване на потвърждаването"</string>
<string name="action_static_map_load">"Докоснете за зареждане на карта"</string>
<string name="action_take_photo">"Снимка"</string>
<string name="action_tap_for_options">"Докоснете за опции"</string>
<string name="action_try_again">"Повторен опит"</string>
<string name="action_unpin">"Откачване"</string>
<string name="action_view_source">"Преглед на източника"</string>
<string name="action_yes">"Да"</string>
<string name="action_yes_try_again">"Да, опитай отново"</string>
<string name="common_about">"Относно"</string>
<string name="common_acceptable_use_policy">"Политика за приемлива употреба"</string>
<string name="common_add_account">"Добавяне на акаунт"</string>
<string name="common_advanced_settings">"Разширени настройки"</string>
<string name="common_analytics">"Статистика"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"Напуснахте стаята"</string>
<string name="common_appearance">"Облик"</string>
<string name="common_audio">"Аудио"</string>
<string name="common_blocked_users">"Блокирани потребители"</string>
<string name="common_bubbles">"Мехурчета"</string>
<string name="common_call_started">"Започнато обаждане"</string>
<string name="common_chat_backup">"Резервно копие на чатовете"</string>
<string name="common_copyright">"Авторски права"</string>
<string name="common_creating_room">"Създаване на стая…"</string>
<string name="common_dark">"Тъмен"</string>
<string name="common_decryption_error">"Грешка при разшифроване"</string>
<string name="common_description">"Описание"</string>
<string name="common_developer_options">"Опции за разработчици"</string>
<string name="common_direct_chat">"Директен чат"</string>
<string name="common_do_not_show_this_again">"Не показвай това отново"</string>
<string name="common_download_failed">"Неуспешно изтегляне"</string>
<string name="common_downloading">"Изтегля се"</string>
<string name="common_edited_suffix">"(редактирано)"</string>
<string name="common_editing">"Редактиране"</string>
@ -132,18 +158,22 @@
<string name="common_error">"Грешка"</string>
<string name="common_everyone">"Всеки"</string>
<string name="common_favourite">"Фаворизиране"</string>
<string name="common_favourited">"Фаворизирано"</string>
<string name="common_file">"Файл"</string>
<string name="common_file_deleted">"Файлът е изтрит"</string>
<string name="common_file_saved">"Файлът е запазен"</string>
<string name="common_file_saved_on_disk_android">"Файлът е запазен в Изтеглени"</string>
<string name="common_forward_message">"Препращане на съобщението"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Изображение"</string>
<string name="common_in_reply_to">"В отговор на %1$s"</string>
<string name="common_install_apk_android">"Инсталиране на APK"</string>
<string name="common_invite_unknown_profile">"Този Matrix ID не може да бъде намерен, така че поканата може да не бъде получена."</string>
<string name="common_leaving_room">"Стаята се напуска"</string>
<string name="common_light">"Светъл"</string>
<string name="common_link_copied_to_clipboard">"Връзката е копирана в клипборда"</string>
<string name="common_loading">"Зарежда се…"</string>
<string name="common_loading_more">"Зарежда се още…"</string>
<plurals name="common_many_members">
<item quantity="one">"%d друг"</item>
<item quantity="other">"%d други"</item>
@ -158,13 +188,18 @@
<string name="common_modern">"Модерно"</string>
<string name="common_mute">"Заглушаване"</string>
<string name="common_no_results">"Няма резултати"</string>
<string name="common_no_room_name">"Няма име на стая"</string>
<string name="common_no_space_name">"Няма име на пространство"</string>
<string name="common_not_encrypted">"Без шифроване"</string>
<string name="common_offline">"Офлайн"</string>
<string name="common_open_source_licenses">"Лицензи за отворен код"</string>
<string name="common_or">"или"</string>
<string name="common_password">"Парола"</string>
<string name="common_people">"Хора"</string>
<string name="common_permalink">"Постоянна връзка"</string>
<string name="common_permission">"Разрешение"</string>
<string name="common_pinned">"Закачено"</string>
<string name="common_please_check_internet_connection">"Моля, проверете вашата интернет връзка"</string>
<string name="common_please_wait">"Моля, изчакайте…"</string>
<string name="common_poll_end_confirmation">"Сигурни ли сте, че искате да приключите тази анкета?"</string>
<string name="common_poll_summary">"Анкета: %1$s"</string>
@ -174,19 +209,32 @@
<item quantity="one">"%d глас"</item>
<item quantity="other">"%d гласа"</item>
</plurals>
<string name="common_preparing">"Подготвя се…"</string>
<string name="common_privacy_policy">"Политика за поверителност"</string>
<string name="common_private_room">"Частна стая"</string>
<string name="common_public_room">"Общодостъпна стая"</string>
<string name="common_public_space">"Общодостъпно пространство"</string>
<string name="common_reaction">"Реакция"</string>
<string name="common_reactions">"Реакции"</string>
<string name="common_reason">"Причина"</string>
<string name="common_recovery_key">"Ключ за възстановяване"</string>
<string name="common_refreshing">"Опреснява се…"</string>
<plurals name="common_replies">
<item quantity="one">"%1$d отговор"</item>
<item quantity="other">"%1$d отговора"</item>
</plurals>
<string name="common_replying_to">"В отговор на %1$s"</string>
<string name="common_report_a_bug">"Съобщаване за грешка"</string>
<string name="common_report_a_problem">"Съобщаване за проблем"</string>
<string name="common_report_submitted">"Докладът е изпратен"</string>
<string name="common_rich_text_editor">"Редактор на богат текст"</string>
<string name="common_room">"Стая"</string>
<string name="common_room_name">"Име на стаята"</string>
<string name="common_room_name_placeholder">"напр. името на вашия проект"</string>
<plurals name="common_rooms">
<item quantity="one">"%1$d стая"</item>
<item quantity="other">"%1$d стаи"</item>
</plurals>
<string name="common_saving">"Запазва се"</string>
<string name="common_screen_lock">"Заключване на екрана"</string>
<string name="common_search_for_someone">"Търсене на някого"</string>
@ -197,20 +245,28 @@
<string name="common_sending_failed">"Изпращането е неуспешно"</string>
<string name="common_sent">"Изпратено"</string>
<string name="common_server_not_supported">"Сървърът не се поддържа"</string>
<string name="common_server_url">"URL адрес на сървъра"</string>
<string name="common_settings">"Настройки"</string>
<string name="common_shared_location">"Споделено местоположение"</string>
<string name="common_signing_out">"Излизате"</string>
<string name="common_something_went_wrong_message">"Възникна проблем. Моля, опитайте отново."</string>
<plurals name="common_spaces">
<item quantity="one">"%1$d пространство"</item>
<item quantity="other">"%1$d пространства"</item>
</plurals>
<string name="common_starting_chat">"Започване на чат…"</string>
<string name="common_sticker">"Стикер"</string>
<string name="common_success">"Успешно"</string>
<string name="common_suggestions">"Предложения"</string>
<string name="common_syncing">"Синхронизиране"</string>
<string name="common_system">"Система"</string>
<string name="common_text">"Текст"</string>
<string name="common_third_party_notices">"Уведомления от трети страни"</string>
<string name="common_thread">"Нишка"</string>
<string name="common_topic">"Тема"</string>
<string name="common_topic_placeholder">"За какво се отнася тази стая?"</string>
<string name="common_unable_to_decrypt">"Не може да се разшифрова"</string>
<string name="common_unable_to_invite_message">"Поканите не можаха да бъдат изпратени до един или повече потребители."</string>
<string name="common_unable_to_invite_title">"Не може да се изпрати покана(и)"</string>
<string name="common_unlock">"Отключване"</string>
<string name="common_unmute">"Раззаглушаване"</string>
@ -225,16 +281,35 @@
<string name="common_verify_user">"Потвърждаване на потребителя"</string>
<string name="common_video">"Видео"</string>
<string name="common_voice_message">"Гласово съобщение"</string>
<string name="common_waiting">"Изчаква се…"</string>
<string name="common_waiting_for_decryption_key">"В очакване на това съобщение"</string>
<string name="common_you">"Вие"</string>
<string name="dialog_title_confirmation">"Потвърждение"</string>
<string name="dialog_title_error">"Грешка"</string>
<string name="dialog_title_success">"Успешно"</string>
<string name="dialog_title_warning">"Внимание"</string>
<string name="error_failed_creating_the_permalink">"Неуспешно създаване на постоянна връзка"</string>
<string name="error_failed_loading_map">"%1$s не успя да зареди картата. Моля, опитайте отново по-късно."</string>
<string name="error_failed_loading_messages">"Неуспешно зареждане на съобщения"</string>
<string name="error_failed_locating_user">"%1$s няма достъп до вашето местоположение. Моля, опитайте отново по-късно."</string>
<string name="error_missing_location_auth_android">"%1$s няма разрешение за достъп до вашето местоположение. Можете да активирате достъпа в Настройки."</string>
<string name="error_missing_location_rationale_android">"%1$s няма разрешение за достъп до вашето местоположение. Активирайте достъпа по-долу."</string>
<string name="error_some_messages_have_not_been_sent">"Някои съобщения не са изпратени"</string>
<string name="error_unknown">"Съжаляваме, възникна грешка"</string>
<string name="event_shield_reason_sent_in_clear">"Без шифроване"</string>
<string name="invite_friends_rich_title">"🔐️ Присъединете се към мен в %1$s"</string>
<string name="invite_friends_text">"Хей, говорете с мен в %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="screen_media_picker_error_failed_selection">"Неуспешен избор на мултимедия, моля, опитайте отново."</string>
<string name="screen_pinned_timeline_empty_state_description">"Натиснете върху съобщение и изберете „%1$s“, за да го включите тук."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Закачете важни съобщения, за да могат лесно да бъдат намерени"</string>
<plurals name="screen_pinned_timeline_screen_title">
<item quantity="one">"%1$d закачено съобщение"</item>
<item quantity="other">"%1$d закачени съобщения"</item>
</plurals>
<string name="screen_pinned_timeline_screen_title_empty">"Закачени съобщения"</string>
<string name="screen_room_error_failed_processing_media">"Неуспешна обработка на мултимедия за качване, моля, опитайте отново."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Не могат да бъдат извлечени потребителските данни"</string>
<string name="screen_share_location_title">"Споделяне на местоположение"</string>
<string name="screen_share_my_location_action">"Споделяне на моето местоположение"</string>
<string name="screen_share_open_apple_maps">"Отваряне в Apple Maps"</string>

View file

@ -192,6 +192,7 @@
<string name="common_dark">"Tmavé"</string>
<string name="common_decryption_error">"Chyba dešifrování"</string>
<string name="common_description">"Popis"</string>
<string name="common_deselect_all">"Odznačit vše"</string>
<string name="common_developer_options">"Možnosti pro vývojáře"</string>
<string name="common_device_id">"ID zařízení"</string>
<string name="common_direct_chat">"Přímý chat"</string>
@ -250,6 +251,7 @@ Důvod: %1$s."</string>
<string name="common_name_and_id">"%1$s (%2$s)"</string>
<string name="common_no_results">"Žádné výsledky"</string>
<string name="common_no_room_name">"Žádný název místnosti"</string>
<string name="common_no_space_name">"Žádný název prostoru"</string>
<string name="common_not_encrypted">"Nešifrováno"</string>
<string name="common_offline">"Offline"</string>
<string name="common_open_source_licenses">"Licence s otevřeným zdrojovým kódem"</string>
@ -305,6 +307,7 @@ Důvod: %1$s."</string>
<string name="common_security">"Zabezpečení"</string>
<string name="common_seen_by">"Viděno"</string>
<string name="common_select_account">"Vybrat účet"</string>
<string name="common_select_all">"Vybrat vše"</string>
<string name="common_send_to">"Odeslat do"</string>
<string name="common_sending">"Odesílání…"</string>
<string name="common_sending_failed">"Odeslání se nezdařilo"</string>
@ -419,9 +422,6 @@ Opravdu chcete pokračovat?"</string>
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Tím budete také odstraněni ze všech místností v tomto prostoru."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Tímto budete také odstraněni ze všech místností v tomto prostoru, včetně těch, jejichž jediným správcem jste:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Opustit %1$s?"</string>
<string name="screen_bug_report_a11y_screenshot">"Snímek obrazovky"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Možnosti"</string>
@ -466,6 +466,7 @@ Opravdu chcete pokračovat?"</string>
<string name="screen_share_this_location_action">"Sdílet tuto polohu"</string>
<string name="screen_space_list_description">"Prostory, které jste vytvořili nebo se k nim připojili."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"%1$s prostor"</string>
<string name="screen_space_list_title">"Prostory"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení."</string>

View file

@ -445,9 +445,6 @@ Ydych chi\'n siŵr eich bod am barhau?"</string>
<string name="invite_friends_text">"Hei, siaradwch â mi ar %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"Android %1$s"</string>
<string name="preference_rageshake">"Rageshake i adrodd gwall"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Bydd hyn hefyd yn eich tynnu o bob ystafell yn y gofod hwn."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Bydd hyn hefyd yn eich tynnu o bob ystafell yn y gofod hwn, gan gynnwys y rhai rydych chi\'n unig weinyddwr ar eu cyfer:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Gadael %1$s ?"</string>
<string name="screen_bug_report_a11y_screenshot">"Llun sgrin"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Dewisiadau"</string>

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