Merge remote-tracking branch 'origin/develop' into feature/bma/mutliAccountNotification

This commit is contained in:
Benoit Marty 2025-11-04 16:20:42 +01:00
commit e96cd9e28f
421 changed files with 4365 additions and 4190 deletions

View file

@ -14,15 +14,14 @@ 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
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onSelectAccount(sessionId: SessionId)
fun onAccountSelected(sessionId: SessionId)
fun onCancel()
}
}

View file

@ -17,7 +17,7 @@ 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
import io.element.android.libraries.architecture.callback
@ContributesNode(AppScope::class)
@AssistedInject
@ -26,23 +26,15 @@ class AccountSelectNode(
@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) }
}
private val callback: AccountSelectEntryPoint.Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AccountSelectView(
state = state,
onDismiss = ::onDismiss,
onSelectAccount = ::onSelectAccount,
onDismiss = callback::onCancel,
onSelectAccount = callback::onAccountSelected,
modifier = modifier,
)
}

View file

@ -9,7 +9,6 @@ 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 io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
@ -17,18 +16,11 @@ import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
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)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: AccountSelectEntryPoint.Callback,
): Node {
return parentNode.createNode<AccountSelectNode>(buildContext, listOf(callback))
}
}

View file

@ -32,12 +32,14 @@ class DefaultAccountSelectEntryPointTest {
)
}
val callback = object : AccountSelectEntryPoint.Callback {
override fun onSelectAccount(sessionId: SessionId) = lambdaError()
override fun onAccountSelected(sessionId: SessionId) = lambdaError()
override fun onCancel() = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result).isInstanceOf(AccountSelectNode::class.java)
assertThat(result.plugins).contains(callback)
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2023, 2024 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
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
inline fun <reified I : Plugin> Node.callback(): I {
return requireNotNull(plugins<I>().singleOrNull()) { "Make sure to actually pass a Callback plugin to your node" }
}

View file

@ -18,12 +18,12 @@ android {
testOptions {
unitTests.isIncludeAndroidResources = true
}
dependencies {
implementation(libs.showkase)
testCommonDependencies(libs)
testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose)
testImplementation(libs.test.roborazzi.junit)
}
}
dependencies {
implementation(libs.showkase)
testCommonDependencies(libs)
testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose)
testImplementation(libs.test.roborazzi.junit)
}

View file

@ -11,8 +11,8 @@ plugins {
android {
namespace = "io.element.android.libraries.cryptography.test"
dependencies {
api(projects.libraries.cryptography.api)
}
}
dependencies {
api(projects.libraries.cryptography.api)
}

View file

@ -13,8 +13,8 @@ plugins {
android {
namespace = "io.element.android.libraries.dateformatter.api"
dependencies {
testCommonDependencies(libs)
}
}
dependencies {
testCommonDependencies(libs)
}

View file

@ -30,19 +30,19 @@ android {
)
}
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
}
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
}

View file

@ -11,9 +11,9 @@ plugins {
android {
namespace = "io.element.android.libraries.dateformatter.test"
dependencies {
api(projects.libraries.dateformatter.api)
api(libs.datetime)
}
}
dependencies {
api(projects.libraries.dateformatter.api)
api(libs.datetime)
}

View file

@ -25,24 +25,24 @@ android {
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
api(projects.libraries.compound)
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs)
}
}
dependencies {
api(projects.libraries.compound)
implementation(libs.androidx.compose.material3.windowsizeclass)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.coil.compose)
implementation(libs.vanniktech.blurhash)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs)
}

View file

@ -114,8 +114,7 @@ enum class FeatureFlags(
title = "Sync notifications with WorkManager",
description = "Use WorkManager to schedule notification sync tasks when a push is received." +
" This should improve reliability and battery usage.",
// Enable by default on nightly and debug builds so we can get feedback before enabling it for everyone.
defaultValue = { meta -> meta.buildType != BuildType.RELEASE },
defaultValue = { true },
isFinished = false,
),
}

View file

@ -11,11 +11,11 @@ plugins {
android {
namespace = "io.element.android.libraries.featureflag.test"
dependencies {
api(projects.libraries.featureflag.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.test)
implementation(libs.coroutines.core)
}
}
dependencies {
api(projects.libraries.featureflag.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.test)
implementation(libs.coroutines.core)
}

View file

@ -0,0 +1,19 @@
/*
* 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.auth
/**
* Checks the homeserver's compatibility with Element X.
*/
interface HomeServerLoginCompatibilityChecker {
/**
* Performs the compatibility check given the homeserver's [url].
* @return a `true` value if the homeserver is compatible, `false` if not, or a failure result if the check unexpectedly failed.
*/
suspend fun check(url: String): Result<Boolean>
}

View file

@ -33,6 +33,7 @@ import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk_crypto.CollectStrategy
@ -105,12 +106,13 @@ class RustMatrixClientFactory(
slidingSyncType: ClientBuilderSlidingSync,
): ClientBuilder {
return clientBuilderProvider.provide()
.sessionPaths(
dataPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
.sqliteStore(
SqliteStoreBuilder(
dataPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
).passphrase(passphrase)
)
.setSessionDelegate(sessionDelegate)
.sessionPassphrase(passphrase)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)

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.matrix.impl.auth
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import timber.log.Timber
@ContributesBinding(AppScope::class)
@Inject
class RustHomeServerLoginCompatibilityChecker(
private val clientBuilderProvider: ClientBuilderProvider,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result<Boolean> = runCatchingExceptions {
clientBuilderProvider.provide()
.inMemoryStore()
.serverNameOrHomeserverUrl(url)
.build()
.use {
it.homeserverLoginDetails()
}
.use {
Timber.d("Homeserver $url | OIDC: ${it.supportsOidcLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}")
it.supportsOidcLogin() || it.supportsPasswordLogin()
}
}
}

View file

@ -285,7 +285,6 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = makeQrCodeLoginClient(
sessionPaths = emptySessionPaths,
passphrase = pendingPassphrase,
qrCodeData = sdkQrCodeLoginData,
)
client.loginWithQrCode(
@ -344,7 +343,6 @@ class RustMatrixAuthenticationService(
private suspend fun makeQrCodeLoginClient(
sessionPaths: SessionPaths,
passphrase: String?,
qrCodeData: QrCodeData,
): Client {
Timber.d("Creating client for QR Code login with simplified sliding sync")
@ -354,7 +352,6 @@ class RustMatrixAuthenticationService(
passphrase = pendingPassphrase,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
.sessionPassphrase(passphrase)
.serverNameOrHomeserverUrl(qrCodeData.serverName()!!)
.build()
}

View file

@ -39,6 +39,7 @@ class NotificationMapper(
isDirect = item.roomInfo.isDirect,
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
)
val timestamp = item.timestamp() ?: clock.epochMillis()
NotificationData(
sessionId = sessionId,
eventId = eventId,
@ -53,8 +54,8 @@ class NotificationMapper(
isDm = isDm,
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = item.isNoisy.orFalse(),
timestamp = item.timestamp() ?: clock.epochMillis(),
content = item.event.use { notificationContentMapper.map(it) }.getOrThrow(),
timestamp = timestamp,
content = notificationContentMapper.map(item.event).getOrThrow(),
hasMention = item.hasMention.orFalse(),
)
}

View file

@ -25,8 +25,9 @@ class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
return runCatchingExceptions {
timelineEvent.use {
val senderId = UserId(timelineEvent.senderId())
timelineEvent.eventType().use { eventType ->
eventType.toContent(senderId = UserId(timelineEvent.senderId()))
eventType.toContent(senderId = senderId)
}
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.impl.auth
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails
import kotlinx.coroutines.test.runTest
import org.junit.Ignore
import org.junit.Test
@Ignore("JNA direct mapping has broken unit tests with FFI fakes")
class RustHomeserverLoginCompatibilityCheckerTest {
@Test
fun `check - is valid if it supports OIDC login`() = runTest {
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsOidcLogin = true) }
assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue()
}
@Test
fun `check - is valid if it supports password login`() = runTest {
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsPasswordLogin = true) }
assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue()
}
@Test
fun `check - is not valid if it only supports SSO login`() = runTest {
val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsSsoLogin = true) }
assertThat(sut.check("https://matrix.host.org").getOrNull()).isFalse()
}
@Test
fun `check - is not valid if fetching the data fails`() = runTest {
val sut = createChecker { error("Unexpected error!") }
assertThat(sut.check("https://matrix.host.org").isFailure).isTrue()
}
private fun createChecker(
result: () -> FakeFfiHomeserverLoginDetails,
) = RustHomeServerLoginCompatibilityChecker(
clientBuilderProvider = FakeClientBuilderProvider {
FakeFfiClientBuilder {
FakeFfiClient(homeserverLoginDetailsResult = result)
}
}
)
}

View file

@ -13,6 +13,7 @@ import org.matrix.rustcomponents.sdk.ClientSessionDelegate
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder
import uniffi.matrix_sdk.BackupDownloadStrategy
import uniffi.matrix_sdk_crypto.CollectStrategy
import uniffi.matrix_sdk_crypto.DecryptionSettings
@ -29,7 +30,6 @@ class FakeFfiClientBuilder(
override fun decryptionSettings(decryptionSettings: DecryptionSettings): ClientBuilder = this
override fun disableSslVerification() = this
override fun homeserverUrl(url: String) = this
override fun sessionPassphrase(passphrase: String?) = this
override fun proxy(url: String) = this
override fun requestConfig(config: RequestConfig) = this
override fun roomKeyRecipientStrategy(strategy: CollectStrategy) = this
@ -42,5 +42,6 @@ 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 fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this
override suspend fun build() = buildResult()
}

View file

@ -12,10 +12,12 @@ import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiHomeserverLoginDetails(
private val url: String = "https://example.org",
private val supportsPasswordLogin: Boolean = true,
private val supportsOidcLogin: Boolean = false
private val supportsPasswordLogin: Boolean = false,
private val supportsOidcLogin: Boolean = false,
private val supportsSsoLogin: Boolean = false,
) : HomeserverLoginDetails(NoHandle) {
override fun url(): String = url
override fun supportsOidcLogin(): Boolean = supportsOidcLogin
override fun supportsPasswordLogin(): Boolean = supportsPasswordLogin
override fun supportsSsoLogin(): Boolean = supportsSsoLogin
}

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.
*/
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
class FakeHomeServerLoginCompatibilityChecker(
private val checkResult: (String) -> Result<Boolean>,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result<Boolean> {
return checkResult(url)
}
}

View file

@ -13,12 +13,12 @@ plugins {
android {
namespace = "io.element.android.libraries.mediapickers.api"
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
testCommonDependencies(libs)
}
}
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
testCommonDependencies(libs)
}

View file

@ -15,10 +15,10 @@ setupDependencyInjection()
android {
namespace = "io.element.android.libraries.mediapickers.test"
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
api(projects.libraries.mediapickers.api)
}
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
api(projects.libraries.mediapickers.api)
}

View file

@ -14,16 +14,15 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
interface MediaGalleryEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onBackClick()
fun onViewInTimeline(eventId: EventId)
fun forwardEvent(eventId: EventId)
fun viewInTimeline(eventId: EventId)
fun forward(eventId: EventId, fromPinnedEvents: Boolean)
}
}

View file

@ -19,19 +19,19 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.parcelize.Parcelize
interface MediaViewerEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: Params,
callback: Callback,
): Node
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun params(params: Params): NodeBuilder
fun avatar(filename: String, avatarUrl: String): NodeBuilder
fun build(): Node
}
fun createParamsForAvatar(filename: String, avatarUrl: String): Params
interface Callback : Plugin {
fun onDone()
fun onViewInTimeline(eventId: EventId)
fun onForwardEvent(eventId: EventId)
fun viewInTimeline(eventId: EventId)
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
}
data class Params(

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.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 io.element.android.libraries.architecture.createNode
@ -18,18 +17,14 @@ import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFl
@ContributesBinding(AppScope::class)
class DefaultMediaGalleryEntryPoint : MediaGalleryEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaGalleryEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : MediaGalleryEntryPoint.NodeBuilder {
override fun callback(callback: MediaGalleryEntryPoint.Callback): MediaGalleryEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<MediaGalleryFlowNode>(buildContext, plugins)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: MediaGalleryEntryPoint.Callback,
): Node {
return parentNode.createNode<MediaGalleryFlowNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
}

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.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 io.element.android.libraries.architecture.createNode
@ -22,52 +21,42 @@ import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@ContributesBinding(AppScope::class)
class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaViewerEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
return MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = filename,
fileSize = null,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
)
}
return object : MediaViewerEntryPoint.NodeBuilder {
override fun callback(callback: MediaViewerEntryPoint.Callback): MediaViewerEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun params(params: MediaViewerEntryPoint.Params): MediaViewerEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun avatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.NodeBuilder {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
return params(
MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = filename,
fileSize = null,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
)
)
}
override fun build(): Node {
return parentNode.createNode<MediaViewerNode>(buildContext, plugins)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: MediaViewerEntryPoint.Params,
callback: MediaViewerEntryPoint.Callback,
): Node {
return parentNode.createNode<MediaViewerNode>(
buildContext = buildContext,
plugins = listOf(params, callback),
)
}
}

View file

@ -13,10 +13,10 @@ 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 com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
@ -38,33 +38,19 @@ class MediaGalleryNode(
interface Callback : Plugin {
fun onBackClick()
fun onItemClick(item: MediaItem.Event)
fun onViewInTimeline(eventId: EventId)
fun onForward(eventId: EventId)
fun showItem(item: MediaItem.Event)
fun viewInTimeline(eventId: EventId)
fun forward(eventId: EventId)
}
private fun onBackClick() {
plugins<Callback>().forEach {
it.onBackClick()
}
}
private val callback: Callback = callback()
override fun onViewInTimelineClick(eventId: EventId) {
plugins<Callback>().forEach {
it.onViewInTimeline(eventId)
}
callback.viewInTimeline(eventId)
}
override fun onForwardClick(eventId: EventId) {
plugins<Callback>().forEach {
it.onForward(eventId)
}
}
private fun onItemClick(item: MediaItem.Event) {
plugins<Callback>().forEach {
it.onItemClick(item)
}
callback.forward(eventId)
}
@Composable
@ -75,8 +61,8 @@ class MediaGalleryNode(
val state = presenter.present()
MediaGalleryView(
state = state,
onBackClick = ::onBackClick,
onItemClick = ::onItemClick,
onBackClick = callback::onBackClick,
onItemClick = callback::showItem,
modifier = modifier,
)
}

View file

@ -13,13 +13,13 @@ 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 com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
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
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
@ -70,41 +70,25 @@ class MediaGalleryFlowNode(
) : NavTarget
}
private fun onBackClick() {
plugins<MediaGalleryEntryPoint.Callback>().forEach {
it.onBackClick()
}
}
private fun onViewInTimeline(eventId: EventId) {
plugins<MediaGalleryEntryPoint.Callback>().forEach {
it.onViewInTimeline(eventId)
}
}
private fun forwardEvent(eventId: EventId) {
plugins<MediaGalleryEntryPoint.Callback>().forEach {
it.forwardEvent(eventId)
}
}
private val callback: MediaGalleryEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : MediaGalleryNode.Callback {
override fun onBackClick() {
this@MediaGalleryFlowNode.onBackClick()
callback.onBackClick()
}
override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryFlowNode.onViewInTimeline(eventId)
override fun viewInTimeline(eventId: EventId) {
callback.viewInTimeline(eventId)
}
override fun onForward(eventId: EventId) {
forwardEvent(eventId)
override fun forward(eventId: EventId) {
callback.forward(eventId, fromPinnedEvents = false)
}
override fun onItemClick(item: MediaItem.Event) {
override fun showItem(item: MediaItem.Event) {
val mode = when (item) {
is MediaItem.Audio,
is MediaItem.Voice,
@ -131,28 +115,28 @@ class MediaGalleryFlowNode(
overlay.hide()
}
override fun onViewInTimeline(eventId: EventId) {
this@MediaGalleryFlowNode.onViewInTimeline(eventId)
override fun viewInTimeline(eventId: EventId) {
callback.viewInTimeline(eventId)
}
override fun onForwardEvent(eventId: EventId) {
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Need to go to the parent because of the overlay
forwardEvent(eventId)
callback.forward(eventId, fromPinnedEvents)
}
}
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
.params(
MediaViewerEntryPoint.Params(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
)
)
.callback(callback)
.build()
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = MediaViewerEntryPoint.Params(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
),
callback = callback,
)
}
}
}

View file

@ -11,6 +11,6 @@ import io.element.android.libraries.matrix.api.core.EventId
interface MediaViewerNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean)
fun onItemDeleted()
}

View file

@ -15,7 +15,6 @@ 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 com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
@ -23,6 +22,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -57,28 +57,19 @@ class MediaViewerNode(
private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val callback: MediaViewerEntryPoint.Callback = callback()
private val inputs = inputs<MediaViewerEntryPoint.Params>()
private fun onDone() {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onDone()
}
}
override fun onViewInTimelineClick(eventId: EventId) {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onViewInTimeline(eventId)
}
callback.viewInTimeline(eventId)
}
override fun onForwardClick(eventId: EventId) {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onForwardEvent(eventId)
}
override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) {
callback.forwardEvent(eventId, fromPinnedEvents)
}
override fun onItemDeleted() {
onDone()
callback.onDone()
}
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
@ -90,11 +81,7 @@ class MediaViewerNode(
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = when (val mode = inputs.mode) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> mode.timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> mode.timelineMode
else -> null
}
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
null -> timelineMediaGalleryDataSource
Timeline.Mode.Live,
@ -153,8 +140,16 @@ class MediaViewerNode(
textFileViewer = textFileViewer,
modifier = modifier,
audioFocus = audioFocus,
onBackClick = ::onDone,
onBackClick = callback::onDone,
)
}
}
}
internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? {
return when (this) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode
else -> null
}
}

View file

@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@ -119,7 +120,10 @@ class MediaViewerPresenter(
}
is MediaViewerEvents.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(event.eventId)
navigator.onForwardClick(
eventId = event.eventId,
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents,
)
}
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(

View file

@ -27,6 +27,7 @@ class SingleMediaGalleryDataSource(
override fun start() = Unit
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
override suspend fun deleteItem(eventId: EventId) = Unit

View file

@ -9,13 +9,12 @@ package io.element.android.libraries.mediaviewer.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFlowNode
import io.element.android.libraries.mediaviewer.test.FakeMediaViewerEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
@ -35,19 +34,19 @@ class DefaultMediaGalleryEntryPointTest {
MediaGalleryFlowNode(
buildContext = buildContext,
plugins = plugins,
mediaViewerEntryPoint = object : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext) = lambdaError()
},
mediaViewerEntryPoint = FakeMediaViewerEntryPoint(),
)
}
val callback = object : MediaGalleryEntryPoint.Callback {
override fun onBackClick() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId) = lambdaError()
override fun viewInTimeline(eventId: EventId) = lambdaError()
override fun forward(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result).isInstanceOf(MediaGalleryFlowNode::class.java)
assertThat(result.plugins).contains(callback)
}

View file

@ -71,14 +71,16 @@ class DefaultMediaViewerEntryPointTest {
}
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun onForwardEvent(eventId: EventId) = lambdaError()
override fun viewInTimeline(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val params = createMediaViewerEntryPointParams()
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.params(params)
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains(params)
assertThat(result.plugins).contains(callback)
@ -115,16 +117,19 @@ class DefaultMediaViewerEntryPointTest {
}
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onViewInTimeline(eventId: EventId) = lambdaError()
override fun onForwardEvent(eventId: EventId) = lambdaError()
override fun viewInTimeline(eventId: EventId) = lambdaError()
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.avatar(
filename = "fn",
avatarUrl = "avatarUrl",
)
.callback(callback)
.build()
val params = entryPoint.createParamsForAvatar(
filename = "fn",
avatarUrl = "avatarUrl",
)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(MediaViewerNode::class.java)
assertThat(result.plugins).contains(
MediaViewerEntryPoint.Params(

View file

@ -12,15 +12,15 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaViewerNavigator(
private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() },
private val onForwardClickLambda: (EventId, Boolean) -> Unit = { _, _ -> lambdaError() },
private val onItemDeletedLambda: () -> Unit = { lambdaError() },
) : MediaViewerNavigator {
override fun onViewInTimelineClick(eventId: EventId) {
onViewInTimelineClickLambda(eventId)
}
override fun onForwardClick(eventId: EventId) {
onForwardClickLambda(eventId)
override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) {
onForwardClickLambda(eventId, fromPinnedEvents)
}
override fun onItemDeleted() {

View file

@ -785,7 +785,7 @@ class MediaViewerPresenterTest {
@Test
fun `present - forward hides the bottom sheet and invokes the navigator`() = runTest {
val onForwardClickLambda = lambdaRecorder<EventId, Unit> { }
val onForwardClickLambda = lambdaRecorder<EventId, Boolean, Unit> { _, _ -> }
val navigator = FakeMediaViewerNavigator(
onForwardClickLambda = onForwardClickLambda,
)
@ -804,7 +804,35 @@ class MediaViewerPresenterTest {
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
onForwardClickLambda.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(false))
}
}
@Test
fun `present - forward from pinned events hides the bottom sheet and invokes the navigator`() = runTest {
val onForwardClickLambda = lambdaRecorder<EventId, Boolean, Unit> { _, _ -> }
val navigator = FakeMediaViewerNavigator(
onForwardClickLambda = onForwardClickLambda,
)
val presenter = createMediaViewerPresenter(
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.PinnedEvents),
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
val withBottomSheetState = awaitItem()
assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
onForwardClickLambda.assertions().isCalledOnce()
.with(value(AN_EVENT_ID), value(true))
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.mediaviewer.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaGalleryEntryPoint : MediaGalleryEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: MediaGalleryEntryPoint.Callback,
): Node = lambdaError()
}

View file

@ -0,0 +1,24 @@
/*
* 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.mediaviewer.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMediaViewerEntryPoint : MediaViewerEntryPoint {
override fun createParamsForAvatar(filename: String, avatarUrl: String) = lambdaError()
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: MediaViewerEntryPoint.Params,
callback: MediaViewerEntryPoint.Callback,
): Node = lambdaError()
}

View file

@ -125,7 +125,7 @@ class DefaultPermissionsPresenter(
showDialog = showDialog.value,
permissionAlreadyAsked = isAlreadyAsked,
permissionAlreadyDenied = isAlreadyDenied,
eventSink = { handleEvents(it) }
eventSink = ::handleEvents,
)
}

View file

@ -11,12 +11,12 @@ plugins {
android {
namespace = "io.element.android.libraries.preferences.test"
dependencies {
api(projects.libraries.preferences.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
implementation(libs.androidx.datastore.preferences)
}
}
dependencies {
api(projects.libraries.preferences.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.core)
implementation(libs.androidx.datastore.preferences)
}

View file

@ -11,11 +11,11 @@ plugins {
android {
namespace = "io.element.android.libraries.previewutils"
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(libs.kotlinx.collections.immutable)
}
}
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(libs.kotlinx.collections.immutable)
}

View file

@ -21,6 +21,12 @@ android {
isIncludeAndroidResources = true
}
}
buildTypes {
register("nightly") {
matchingFallbacks += listOf("release")
}
}
}
setupDependencyInjection()

Binary file not shown.

View file

@ -62,7 +62,7 @@ class IgnoredUsersTest(
coroutineScope: CoroutineScope,
navigator: NotificationTroubleshootNavigator,
) {
navigator.openIgnoredUsers()
navigator.navigateToBlockedUsers()
}
override suspend fun reset() = delegate.reset()

Binary file not shown.

View file

@ -39,7 +39,7 @@ class IgnoredUsersTestTest {
)
val openIgnoredUsersResult = lambdaRecorder<Unit> {}
val navigator = object : NotificationTroubleshootNavigator {
override fun openIgnoredUsers() = openIgnoredUsersResult()
override fun navigateToBlockedUsers() = openIgnoredUsersResult()
}
sut.quickFix(
coroutineScope = backgroundScope,

View file

@ -18,12 +18,12 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
val mode: RoomSelectMode,
)
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: Params,
callback: Callback,
): Node
interface Callback : Plugin {
fun onRoomSelected(roomIds: List<RoomId>)

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.roomselect.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.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ -17,23 +16,18 @@ import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
@ContributesBinding(SessionScope::class)
class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomSelectEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : RoomSelectEntryPoint.NodeBuilder {
override fun params(params: RoomSelectEntryPoint.Params): RoomSelectEntryPoint.NodeBuilder {
plugins += RoomSelectNode.Inputs(mode = params.mode)
return this
}
override fun callback(callback: RoomSelectEntryPoint.Callback): RoomSelectEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<RoomSelectNode>(buildContext, plugins)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: RoomSelectEntryPoint.Params,
callback: RoomSelectEntryPoint.Callback,
): Node {
return parentNode.createNode<RoomSelectNode>(
buildContext = buildContext,
plugins = listOf(
RoomSelectNode.Inputs(mode = params.mode),
callback,
)
)
}
}

View file

@ -16,9 +16,9 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
@ -35,24 +35,15 @@ class RoomSelectNode(
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.mode)
private val callbacks = plugins.filterIsInstance<RoomSelectEntryPoint.Callback>()
private fun onDismiss() {
callbacks.forEach { it.onCancel() }
}
private fun onSubmit(roomIds: List<RoomId>) {
callbacks.forEach { it.onRoomSelected(roomIds) }
}
private val callback: RoomSelectEntryPoint.Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomSelectView(
state = state,
onDismiss = ::onDismiss,
onSubmit = ::onSubmit,
onDismiss = callback::onCancel,
onSubmit = callback::onRoomSelected,
modifier = modifier
)
}

View file

@ -87,7 +87,7 @@ class RoomSelectPresenter(
query = searchQuery,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = { handleEvents(it) }
eventSink = ::handleEvents,
)
}
}

View file

@ -42,10 +42,12 @@ class DefaultRoomSelectEntryPointTest {
override fun onCancel() = lambdaError()
}
val params = RoomSelectEntryPoint.Params(testMode)
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.params(params)
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(RoomSelectNode::class.java)
assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode))
assertThat(result.plugins).contains(callback)

View file

@ -0,0 +1,19 @@
/*
* 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.roomselect.test"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.roomselect.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,22 @@
/*
* 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.roomselect.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeRoomSelectEntryPoint : RoomSelectEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: RoomSelectEntryPoint.Params,
callback: RoomSelectEntryPoint.Callback,
): Node = lambdaError()
}

View file

@ -50,6 +50,11 @@ interface SessionStore {
*/
suspend fun getAllSessions(): List<SessionData>
/**
* Get the number of sessions.
*/
suspend fun numberOfSessions(): Int
/**
* Get the latest session, or null if no session exists.
*/

View file

@ -161,6 +161,15 @@ class DatabaseSessionStore(
}
}
override suspend fun numberOfSessions(): Int {
return sessionDataMutex.withLock {
database.sessionDataQueries.count()
.executeAsOneOrNull()
?.toInt()
?: 0
}
}
override fun sessionsFlow(): Flow<List<SessionData>> {
return database.sessionDataQueries.selectAll()
.asFlow()

View file

@ -47,6 +47,9 @@ SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1;
selectAll:
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC;
count:
SELECT count(*) FROM SessionData;
selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;

View file

@ -52,6 +52,7 @@ class DatabaseSessionStoreTest {
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
}
@Test
@ -109,6 +110,7 @@ class DatabaseSessionStoreTest {
assertThat(foundSession).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2)
}
@Test
@ -196,12 +198,16 @@ class DatabaseSessionStoreTest {
position = 1,
lastUsageIndex = 1,
)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
databaseSessionStore.addSession(secondSessionData.toApiModel())
assertThat(awaitItem().size).isEqualTo(2)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2)
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(awaitItem().size).isEqualTo(1)
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1)
databaseSessionStore.removeSession(secondSessionData.userId)
assertThat(awaitItem()).isEmpty()
assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(0)
}
}

View file

@ -67,6 +67,10 @@ class InMemorySessionStore(
return sessionDataListFlow.value
}
override suspend fun numberOfSessions(): Int {
return sessionDataListFlow.value.size
}
override suspend fun getLatestSession(): SessionData? {
return sessionDataListFlow.value.firstOrNull()
}

View file

@ -2,7 +2,7 @@
<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_bullet_list">"تغییر وضعیت سیاههٔ گلوله‌ای"</string>
<string name="rich_text_editor_close_formatting_options">"بستن گزینه‌های قالب‌بندی"</string>
<string name="rich_text_editor_close_formatting_options">"لغو و بستن قالب‌بندی متن"</string>
<string name="rich_text_editor_code_block">"تغییر حالت بلوک کد"</string>
<string name="rich_text_editor_composer_caption_placeholder">"افزودن عنوان"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"پیام رمزنگاری شده…"</string>

View file

@ -13,15 +13,14 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface NotificationTroubleShootEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onDone()
fun openIgnoredUsers()
fun navigateToBlockedUsers()
}
}

View file

@ -15,15 +15,14 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
interface PushHistoryEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Callback : Plugin {
fun onDone()
fun navigateTo(roomId: RoomId, eventId: EventId)
fun navigateToEvent(roomId: RoomId, eventId: EventId)
}
}

View file

@ -8,5 +8,5 @@
package io.element.android.libraries.troubleshoot.api.test
interface NotificationTroubleshootNavigator {
fun openIgnoredUsers()
fun navigateToBlockedUsers()
}

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.troubleshoot.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 io.element.android.libraries.architecture.createNode
@ -17,18 +16,11 @@ import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEnt
@ContributesBinding(AppScope::class)
class DefaultNotificationTroubleShootEntryPoint : NotificationTroubleShootEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NotificationTroubleShootEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : NotificationTroubleShootEntryPoint.NodeBuilder {
override fun callback(callback: NotificationTroubleShootEntryPoint.Callback): NotificationTroubleShootEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<TroubleshootNotificationsNode>(buildContext, plugins)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: NotificationTroubleShootEntryPoint.Callback,
): Node {
return parentNode.createNode<TroubleshootNotificationsNode>(buildContext, listOf(callback))
}
}

View file

@ -12,11 +12,11 @@ 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 com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator
@ -31,20 +31,13 @@ class TroubleshootNotificationsNode(
factory: TroubleshootNotificationsPresenter.Factory,
) : Node(buildContext, plugins = plugins),
NotificationTroubleshootNavigator {
private val callback: NotificationTroubleShootEntryPoint.Callback = callback()
private val presenter = factory.create(
navigator = this,
)
private fun onDone() {
plugins<NotificationTroubleShootEntryPoint.Callback>().forEach {
it.onDone()
}
}
override fun openIgnoredUsers() {
plugins<NotificationTroubleShootEntryPoint.Callback>().forEach {
it.openIgnoredUsers()
}
override fun navigateToBlockedUsers() {
callback.navigateToBlockedUsers()
}
@Composable
@ -53,7 +46,7 @@ class TroubleshootNotificationsNode(
val state = presenter.present()
TroubleshootNotificationsView(
state = state,
onBackClick = ::onDone,
onBackClick = callback::onDone,
modifier = modifier,
)
}

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.troubleshoot.impl.history
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 io.element.android.libraries.architecture.createNode
@ -17,18 +16,11 @@ import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
@ContributesBinding(AppScope::class)
class DefaultPushHistoryEntryPoint : PushHistoryEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PushHistoryEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : PushHistoryEntryPoint.NodeBuilder {
override fun callback(callback: PushHistoryEntryPoint.Callback): PushHistoryEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<PushHistoryNode>(buildContext, plugins)
}
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: PushHistoryEntryPoint.Callback,
): Node {
return parentNode.createNode<PushHistoryNode>(buildContext, listOf(callback))
}
}

View file

@ -12,11 +12,11 @@ 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 com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
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
@ -31,16 +31,10 @@ class PushHistoryNode(
presenterFactory: PushHistoryPresenter.Factory,
private val screenTracker: ScreenTracker,
) : Node(buildContext, plugins = plugins), PushHistoryNavigator {
private fun onDone() {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onDone()
}
}
private val callback: PushHistoryEntryPoint.Callback = callback()
override fun navigateTo(roomId: RoomId, eventId: EventId) {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.navigateTo(roomId, eventId)
}
callback.navigateToEvent(roomId, eventId)
}
private val presenter = presenterFactory.create(this)
@ -51,7 +45,7 @@ class PushHistoryNode(
val state = presenter.present()
PushHistoryView(
state = state,
onBackClick = ::onDone,
onBackClick = callback::onDone,
modifier = modifier,
)
}

View file

@ -34,11 +34,13 @@ class DefaultNotificationTroubleShootEntryPointTest {
}
val callback = object : NotificationTroubleShootEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun openIgnoredUsers() = lambdaError()
override fun navigateToBlockedUsers() = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result).isInstanceOf(TroubleshootNotificationsNode::class.java)
assertThat(result.plugins).contains(callback)
}

View file

@ -180,7 +180,7 @@ private fun createTroubleshootTestSuite(
internal fun createTroubleshootNotificationsPresenter(
navigator: NotificationTroubleshootNavigator = object : NotificationTroubleshootNavigator {
override fun openIgnoredUsers() = lambdaError()
override fun navigateToBlockedUsers() = lambdaError()
},
troubleshootTestSuite: TroubleshootTestSuite = createTroubleshootTestSuite(),
): TroubleshootNotificationsPresenter {

View file

@ -46,11 +46,13 @@ class DefaultPushHistoryEntryPointTest {
}
val callback = object : PushHistoryEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
override fun navigateToEvent(roomId: RoomId, eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
assertThat(result).isInstanceOf(PushHistoryNode::class.java)
assertThat(result.plugins).contains(callback)
}

View file

@ -13,6 +13,7 @@ android {
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.troubleshoot.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.test)

View file

@ -0,0 +1,21 @@
/*
* 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.troubleshoot.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationTroubleShootEntryPoint : NotificationTroubleShootEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: NotificationTroubleShootEntryPoint.Callback,
): Node = lambdaError()
}

View file

@ -13,5 +13,5 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationTroubleshootNavigator(
private val openIgnoredUsersResult: () -> Unit = { lambdaError() },
) : NotificationTroubleshootNavigator {
override fun openIgnoredUsers() = openIgnoredUsersResult()
override fun navigateToBlockedUsers() = openIgnoredUsersResult()
}

View file

@ -0,0 +1,21 @@
/*
* 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.troubleshoot.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakePushHistoryEntryPoint : PushHistoryEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: PushHistoryEntryPoint.Callback,
): Node = lambdaError()
}

View file

@ -316,6 +316,8 @@
<string name="screen_share_open_google_maps">"Отваряне в Google Maps"</string>
<string name="screen_share_open_osm_maps">"Отваряне в OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Споделяне на това местоположение"</string>
<string name="screen_space_settings_leave_space">"Напускане на пространството"</string>
<string name="screen_space_settings_security_and_privacy">"Защита и поверителност"</string>
<string name="screen_view_location_title">"Местоположение"</string>
<string name="settings_version_number">"Версия: %1$s (%2$s)"</string>
<string name="test_language_identifier">"bg"</string>

View file

@ -97,6 +97,7 @@
<string name="action_forgot_password">"Zapomněli jste heslo?"</string>
<string name="action_forward">"Přeposlat"</string>
<string name="action_go_back">"Přejít zpět"</string>
<string name="action_go_to_roles_and_permissions">"Přejít na role a oprávnění"</string>
<string name="action_go_to_settings">"Přejít do nastavení"</string>
<string name="action_ignore">"Ignorovat"</string>
<string name="action_invite">"Pozvat"</string>
@ -178,7 +179,6 @@
<string name="common_advanced_settings">"Pokročilá nastavení"</string>
<string name="common_an_image">"obrázek"</string>
<string name="common_analytics">"Analytika"</string>
<string name="common_android_notification_sync_notifications_foreground_service_title">"Načítání oznámení…"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"Opustili jste místnost"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"Byli jste odhlášeni z relace"</string>
<string name="common_appearance">"Vzhled"</string>
@ -476,6 +476,9 @@ Opravdu chcete pokračovat?"</string>
<string name="screen_space_list_parent_space">"%1$s prostor"</string>
<string name="screen_space_list_title">"Prostory"</string>
<string name="screen_space_menu_action_members">"Zobrazit členy"</string>
<string name="screen_space_settings_leave_space">"Opustit prostor"</string>
<string name="screen_space_settings_roles_and_permissions">"Role a oprávnění"</string>
<string name="screen_space_settings_security_and_privacy">"Zabezpečení a soukromí"</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>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení."</string>

View file

@ -497,6 +497,8 @@ Ydych chi\'n siŵr eich bod am barhau?"</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"Gofod %1$s"</string>
<string name="screen_space_list_title">"Gofodau"</string>
<string name="screen_space_settings_leave_space">"Gadael y gofod"</string>
<string name="screen_space_settings_security_and_privacy">"Diogelwch a phreifatrwydd"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Heb anfon y neges oherwydd bod hunaniaeth wedi \'i ddilysu %1$s wedi\'i ailosod."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Heb anfon y neges oherwydd nid yw %1$s wedi gwirio pob dyfais."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Heb anfon y neges oherwydd nad ydych wedi gwirio un neu fwy o\'ch dyfeisiau."</string>

View file

@ -466,6 +466,8 @@ Er du sikker på, at du vil fortsætte?"</string>
<string name="screen_space_list_parent_space">"%1$s gruppe"</string>
<string name="screen_space_list_title">"Grupper"</string>
<string name="screen_space_menu_action_members">"Vis medlemmer"</string>
<string name="screen_space_settings_leave_space">"Forlad gruppe"</string>
<string name="screen_space_settings_security_and_privacy">"Sikkerhed og privatliv"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Beskeden blev ikke sendt fordi %1$s s bekræftede identitet blev nulstillet."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Meddelelsen er ikke sendt, fordi %1$s ikke har bekræftet alle enheder."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Beskeden er ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder."</string>

View file

@ -420,6 +420,7 @@ Möchtest du wirklich fortfahren?"</string>
<string name="invite_friends_rich_title">"🔐️ Begleite mich auf %1$s"</string>
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="preference_rageshake">"Heftiges Schütteln um Fehler zu melden"</string>
<string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
@ -466,6 +467,9 @@ Möchtest du wirklich fortfahren?"</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"%1$s Space"</string>
<string name="screen_space_list_title">"Spaces"</string>
<string name="screen_space_menu_action_members">"Mitglieder anzeigen"</string>
<string name="screen_space_settings_leave_space">"Space verlassen"</string>
<string name="screen_space_settings_security_and_privacy">"Sicherheit &amp; Datenschutz"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Nachricht nicht gesendet, weil sich die verifizierte Identität von %1$s geändert hat."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast."</string>

View file

@ -394,6 +394,7 @@
<string name="screen_share_open_google_maps">"Άνοιγμα στο Google Maps"</string>
<string name="screen_share_open_osm_maps">"Άνοιγμα στο OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Κοινή χρήση αυτής της τοποθεσίας"</string>
<string name="screen_space_settings_security_and_privacy">"Ασφάλεια &amp; απόρρητο"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Το μήνυμα δεν στάλθηκε γιατί έγινε επαναφορά της επαληθευμένης ταυτότητας του χρήστη %1$s."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Το μήνυμα δεν στάλθηκε επειδή ο χρήστης %1$s δεν έχει επαληθεύσει όλες τις συσκευές."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Το μήνυμα δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου."</string>

View file

@ -377,6 +377,7 @@ Motivo: %1$s."</string>
<string name="screen_share_open_google_maps">"Abrir en Google Maps"</string>
<string name="screen_share_open_osm_maps">"Abrir en OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Compartir esta ubicación"</string>
<string name="screen_space_settings_security_and_privacy">"Seguridad y privacidad"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Mensaje no enviado porque la identidad verificada de %1$s fue restablecida."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Mensaje no enviado porque %1$s no ha verificado todos los dispositivos."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Mensaje no enviado porque no has verificado uno o más de tus dispositivos."</string>

View file

@ -95,6 +95,7 @@
<string name="action_forgot_password">"Kas unustasid salasõna?"</string>
<string name="action_forward">"Edasta"</string>
<string name="action_go_back">"Tagasi eelmisesse vaatesse"</string>
<string name="action_go_to_roles_and_permissions">"Ava „Rollid ja õigused“"</string>
<string name="action_go_to_settings">"Ava seadistused"</string>
<string name="action_ignore">"Eira"</string>
<string name="action_invite">"Kutsu"</string>
@ -420,6 +421,7 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="invite_friends_rich_title">"🔐️ Liitu minuga rakenduses %1$s"</string>
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_thread_in_room">"Jutulõng „%1$s“ jututoas"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
@ -467,6 +469,9 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="screen_space_list_parent_space">"Kogukond: %1$s"</string>
<string name="screen_space_list_title">"Kogukonnad"</string>
<string name="screen_space_menu_action_members">"Vaata liikmeid"</string>
<string name="screen_space_settings_leave_space">"Lahku kogukonnast"</string>
<string name="screen_space_settings_roles_and_permissions">"Rollid ja õigused"</string>
<string name="screen_space_settings_security_and_privacy">"Turvalisus ja privaatsus"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Kuna sa pole üks või enamgi oma seadet verifitseerinud, siis sinu sõnum on saatmata."</string>

View file

@ -375,6 +375,7 @@ Ziur jarraitu nahi duzula?"</string>
<string name="screen_share_open_google_maps">"Ireki Google Maps-en"</string>
<string name="screen_share_open_osm_maps">"Ireki OpenStreetMap-en"</string>
<string name="screen_share_this_location_action">"Partekatu kokapen hau"</string>
<string name="screen_space_settings_security_and_privacy">"Segurtasuna eta pribatutasuna"</string>
<string name="screen_view_location_title">"Kokapena"</string>
<string name="settings_version_number">"Bertsioa: %1$s (%2$s)"</string>
<string name="test_language_identifier">"eu"</string>

View file

@ -383,6 +383,8 @@
<string name="screen_space_list_parent_space">"%1$s فضا"</string>
<string name="screen_space_list_title">"فضاها"</string>
<string name="screen_space_menu_action_members">"دیدن اعضا"</string>
<string name="screen_space_settings_leave_space">"ترک فضا"</string>
<string name="screen_space_settings_security_and_privacy">"امنیت و محرمانگی"</string>
<string name="screen_view_location_title">"مکان"</string>
<string name="settings_version_number">"نگارش : %1$s (%2$s)"</string>
<string name="test_language_identifier">"fa"</string>

View file

@ -467,6 +467,8 @@ Haluatko varmasti jatkaa?"</string>
<string name="screen_space_list_parent_space">"%1$s tila"</string>
<string name="screen_space_list_title">"Tilat"</string>
<string name="screen_space_menu_action_members">"Näytä jäsenet"</string>
<string name="screen_space_settings_leave_space">"Poistu tilasta"</string>
<string name="screen_space_settings_security_and_privacy">"Turvallisuus ja yksityisyys"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Viestiä ei lähetetty, koska käyttäjän %1$s vahvistettu identiteetti nollattiin."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Viestiä ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Viestiä ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi."</string>

View file

@ -467,6 +467,8 @@ Raison : %1$s."</string>
<string name="screen_space_list_parent_space">"Espace %1$s"</string>
<string name="screen_space_list_title">"Espaces"</string>
<string name="screen_space_menu_action_members">"Voir les membres"</string>
<string name="screen_space_settings_leave_space">"Quitter lespace"</string>
<string name="screen_space_settings_security_and_privacy">"Sécurité &amp; confidentialité"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Le message na pas été envoyé car lidentité vérifiée de %1$s a été réinitialisée."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Le message na pas été envoyé car %1$s na pas vérifié tous ses appareils."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Message non envoyé car vous navez pas vérifié tous vos appareils."</string>

View file

@ -465,6 +465,8 @@ Biztos, hogy folytatja?"</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"%1$s tér"</string>
<string name="screen_space_list_title">"Terek"</string>
<string name="screen_space_settings_leave_space">"Tér elhagyása"</string>
<string name="screen_space_settings_security_and_privacy">"Biztonság és adatvédelem"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Az üzenet nem lett elküldve, mert %1$s ellenőrzött személyazonossága megváltozott."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte."</string>

View file

@ -399,6 +399,7 @@ Apakah Anda yakin ingin melanjutkan?"</string>
<string name="screen_share_open_google_maps">"Buka di Google Maps"</string>
<string name="screen_share_open_osm_maps">"Buka di OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Bagikan lokasi ini"</string>
<string name="screen_space_settings_security_and_privacy">"Keamanan &amp; privasi"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Pesan tidak terkirim karena identitas terverifikasi %1$s telah diatur ulang."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Pesan tidak terkirim karena %1$s belum memverifikasi semua perangkat."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Pesan tidak terkirim karena Anda belum memverifikasi satu atau beberapa perangkat Anda."</string>

View file

@ -447,6 +447,7 @@ Sei sicuro di voler continuare?"</string>
<string name="screen_space_list_description">"Spazi che hai creato o a cui hai aderito."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_title">"Spazi"</string>
<string name="screen_space_settings_security_and_privacy">"Sicurezza e privacy"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Messaggio non inviato perché l\'identità verificata di %1$s è stata reimpostata."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Messaggio non inviato perché %1$s non ha verificato tutti i dispositivi."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Messaggio non inviato perché non hai verificato uno o più dispositivi."</string>

View file

@ -440,6 +440,7 @@
<string name="screen_space_list_description">"당신이 스페이스를 만들거나 가입했습니다."</string>
<string name="screen_space_list_details">"%1$s•%2$s"</string>
<string name="screen_space_list_title">"스페이스"</string>
<string name="screen_space_settings_security_and_privacy">"보안 및 개인정보 보호"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"%1$s의 인증된 신원이 재설정되어 메시지가 전송되지 않았습니다."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"%1$s 이 모든 장치를 확인하지 않았기 때문에 메시지가 전송되지 않았습니다."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"하나 이상의 기기를 확인하지 않았기 때문에 메시지가 전송되지 않았습니다."</string>

View file

@ -465,6 +465,8 @@ Er du sikker på at du vil fortsette?"</string>
<string name="screen_space_list_parent_space">"%1$s område"</string>
<string name="screen_space_list_title">"Områder"</string>
<string name="screen_space_menu_action_members">"Vis medlemmer"</string>
<string name="screen_space_settings_leave_space">"Forlat område"</string>
<string name="screen_space_settings_security_and_privacy">"Sikkerhet og personvern"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Meldingen ble ikke sendt fordi %1$ss verifiserte identitet er tilbakestilt."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Meldingen ble ikke sendt fordi %1$s ikke har verifisert alle enheter."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Meldingen ble ikke sendt fordi du ikke har verifisert en eller flere av enhetene dine."</string>

View file

@ -475,6 +475,8 @@ Czy na pewno chcesz kontynuować?"</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"Przestrzeń %1$s"</string>
<string name="screen_space_list_title">"Przestrzenie"</string>
<string name="screen_space_settings_leave_space">"Opuść przestrzeń"</string>
<string name="screen_space_settings_security_and_privacy">"Bezpieczeństwo i prywatność"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Wiadomość nie została wysłana, ponieważ tożsamość %1$s została zresetowana."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Wiadomość nie została wysłana, ponieważ %1$s nie zweryfikował wszystkich urządzeń."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Wiadomość nie została wysłana, ponieważ nie zweryfikowałeś jednego lub więcej swoich urządzeń."</string>

View file

@ -428,6 +428,7 @@ Você tem certeza de que deseja continuar?"</string>
<string name="screen_space_list_description">"Os espaços que você criou ou entrou."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_title">"Espaços"</string>
<string name="screen_space_settings_security_and_privacy">"Segurança e privacidade"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Mensagem não enviada porque a identidade verificada de %1$s foi redefinida."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"A mensagem não foi enviada porque %1$s não verificou todos os dispositivos."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Mensagem não enviada porque você não verificou um ou mais dos seus dispositivos."</string>

View file

@ -461,6 +461,8 @@ Tens a certeza de que queres continuar?"</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"Espaço %1$s"</string>
<string name="screen_space_list_title">"Espaços"</string>
<string name="screen_space_settings_leave_space">"Sair do espaço"</string>
<string name="screen_space_settings_security_and_privacy">"Segurança e privacidade"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Mensagem não enviada porque a identidade verificada de %1$s foi reposta."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Mensagem não enviada porque %1$s não verificou todos os dispositivos."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Mensagem não enviada porque não verificou um ou mais dos seus dispositivos."</string>

View file

@ -475,6 +475,8 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"Spațiu %1$s"</string>
<string name="screen_space_list_title">"Spații"</string>
<string name="screen_space_settings_leave_space">"Părăsiți spațiul"</string>
<string name="screen_space_settings_security_and_privacy">"Securitate &amp; confidențialitate"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Mesajul nu a fost trimis deoarece identitatea verificată a lui %1$s s-a schimbat."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Mesajul nu a fost trimis deoarece %1$s nu a verificat toate dispozitivele."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive."</string>

View file

@ -473,6 +473,8 @@
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"%1$s пространство"</string>
<string name="screen_space_list_title">"Пространства"</string>
<string name="screen_space_settings_leave_space">"Покинуть пространство"</string>
<string name="screen_space_settings_security_and_privacy">"Безопасность и конфиденциальность"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Сообщение не отправлено, потому что подтвержденная личность %1$s была сброшена."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Сообщение не отправлено, потому что %1$s не проверил одно или несколько устройств."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Сообщение не отправлено, поскольку вы не подтвердили одно или несколько своих устройств."</string>

View file

@ -97,6 +97,7 @@
<string name="action_forgot_password">"Zabudnuté heslo?"</string>
<string name="action_forward">"Preposlať"</string>
<string name="action_go_back">"Ísť späť"</string>
<string name="action_go_to_roles_and_permissions">"Prejsť na roly a oprávnenia"</string>
<string name="action_go_to_settings">"Prejsť na nastavenia"</string>
<string name="action_ignore">"Ignorovať"</string>
<string name="action_invite">"Pozvať"</string>
@ -178,7 +179,6 @@
<string name="common_advanced_settings">"Pokročilé nastavenia"</string>
<string name="common_an_image">"obrázok"</string>
<string name="common_analytics">"Analytika"</string>
<string name="common_android_notification_sync_notifications_foreground_service_title">"Načítavajú sa upozornenia…"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"Opustili ste miestnosť"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"Boli ste odhlásení zo relácie."</string>
<string name="common_appearance">"Vzhľad"</string>
@ -478,6 +478,9 @@ Naozaj chcete pokračovať?"</string>
<string name="screen_space_list_parent_space">"%1$s priestor"</string>
<string name="screen_space_list_title">"Priestory"</string>
<string name="screen_space_menu_action_members">"Zobraziť členov"</string>
<string name="screen_space_settings_leave_space">"Opustiť priestor"</string>
<string name="screen_space_settings_roles_and_permissions">"Roly a oprávnenia"</string>
<string name="screen_space_settings_security_and_privacy">"Bezpečnosť a súkromie"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Správa nebola odoslaná, pretože sa zmenila overená totožnosť používateľa %1$s."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Správa nebola odoslaná, pretože %1$s neoveril/a všetky zariadenia."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení."</string>

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