Merge branch 'develop' into separate_import_error

This commit is contained in:
Hubert Chathi 2025-10-02 14:33:55 -04:00 committed by GitHub
commit 8f8e190e68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2773 changed files with 29051 additions and 10914 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* 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.
@ -9,10 +9,10 @@ plugins {
}
android {
namespace = "io.element.android.libraries.sessionstorage.impl.memory"
namespace = "io.element.android.libraries.accountselect.api"
}
dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(libs.coroutines.core)
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

@ -1,4 +1,5 @@
import extension.setupAnvil
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -18,26 +19,22 @@ android {
}
}
setupAnvil()
setupDependencyInjection()
dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.core)
implementation(projects.services.toolbox.api)
implementation(libs.dagger)
implementation(libs.timber)
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.activity)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.datastore.preferences)
api(libs.androidx.browser)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(projects.services.toolbox.test)
}

View file

@ -11,15 +11,16 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidClipboardHelper @Inject constructor(
@Inject
class AndroidClipboardHelper(
@ApplicationContext private val context: Context,
) : ClipboardHelper {
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())

View file

@ -9,11 +9,11 @@ package io.element.android.libraries.androidutils.file
import android.content.Context
import android.net.Uri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
interface TemporaryUriDeleter {
/**
@ -23,7 +23,8 @@ interface TemporaryUriDeleter {
}
@ContributesBinding(AppScope::class)
class DefaultTemporaryUriDeleter @Inject constructor(
@Inject
class DefaultTemporaryUriDeleter(
@ApplicationContext private val context: Context,
) : TemporaryUriDeleter {
private val baseCacheUri = "content://${context.packageName}.fileprovider/cache"

View file

@ -10,14 +10,15 @@ package io.element.android.libraries.androidutils.filesize
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidFileSizeFormatter @Inject constructor(
@Inject
class AndroidFileSizeFormatter(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider,
) : FileSizeFormatter {

View file

@ -27,7 +27,11 @@ class VideoCompressorHelper(
fun getOutputSize(inputSize: Size): Size {
val resultMajor = min(inputSize.major(), maxSize)
val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat()
return Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
return if (inputSize.isLandscape()) {
Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
} else {
Size((resultMajor / aspectRatio).roundToInt(), resultMajor)
}
}
/**
@ -42,5 +46,6 @@ class VideoCompressorHelper(
}
}
internal fun Size.major(): Int = if (width > height) width else height
internal fun Size.minor(): Int = if (width < height) width else height
private fun Size.isLandscape(): Boolean = width > height
private fun Size.major(): Int = if (isLandscape()) width else height
private fun Size.minor(): Int = if (isLandscape()) height else width

View file

@ -0,0 +1,26 @@
/*
* 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.androidutils.preferences
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
object DefaultPreferencesCorruptionHandlerFactory {
/**
* Creates a [ReplaceFileCorruptionHandler] that will replace the corrupted preferences file with an empty preferences object.
*/
fun replaceWithEmpty(): ReplaceFileCorruptionHandler<Preferences> {
return ReplaceFileCorruptionHandler(
produceNewData = {
// If the preferences file is corrupted, we return an empty preferences object
emptyPreferences()
},
)
}
}

View file

@ -11,15 +11,15 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.androidutils.system.DateTimeObserver.Event
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.time.Instant
import javax.inject.Inject
interface DateTimeObserver {
val changes: Flow<Event>
@ -32,7 +32,8 @@ interface DateTimeObserver {
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultDateTimeObserver @Inject constructor(
@Inject
class DefaultDateTimeObserver(
@ApplicationContext context: Context
) : DateTimeObserver {
private val dateTimeReceiver = object : BroadcastReceiver() {

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"이 동작을 수행할 수 있는 앱을 찾지 못했습니다."</string>
</resources>

View file

@ -0,0 +1,79 @@
/*
* 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.androidutils.media
import android.util.Size
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class VideoCompressorHelperTest {
@Test
fun `test getOutputSize`() {
val helper = VideoCompressorHelper(maxSize = 720)
// Landscape input
var inputSize = Size(1920, 1080)
var outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 405))
// Landscape input small height
inputSize = Size(1920, 200)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 75))
// Portrait input
inputSize = Size(1080, 1920)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(405, 720))
// Portrait input small width
inputSize = Size(200, 1920)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(75, 720))
// Square input
inputSize = Size(1000, 1000)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 720))
// Square input same size
inputSize = Size(720, 720)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(720, 720))
// Square input no downscaling
inputSize = Size(240, 240)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(240, 240))
// Small input landscape (no downscaling)
inputSize = Size(640, 480)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(640, 480))
// Small input portrait (no downscaling)
inputSize = Size(480, 640)
outputSize = helper.getOutputSize(inputSize)
assertThat(outputSize).isEqualTo(Size(480, 640))
}
@Test
fun `test calculateOptimalBitrate`() {
val helper = VideoCompressorHelper(maxSize = 720)
val inputSize = Size(1920, 1080)
var bitrate = helper.calculateOptimalBitrate(inputSize, frameRate = 30)
// Output size will be 720x405, so bitrate = 720*405*0.1*30 = 874800
assertThat(bitrate).isEqualTo(874_800L)
// Half frame rate, half bitrate
bitrate = helper.calculateOptimalBitrate(inputSize, frameRate = 15)
assertThat(bitrate).isEqualTo(437_400L)
}
}

View file

@ -1,3 +1,6 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
*
@ -13,15 +16,15 @@ android {
namespace = "io.element.android.libraries.architecture"
}
setupDependencyInjection()
dependencies {
api(projects.libraries.di)
api(projects.libraries.core)
api(libs.dagger)
api(libs.metro.runtime)
api(libs.appyx.core)
api(libs.androidx.lifecycle.runtime)
api(libs.molecule.runtime)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}

View file

@ -11,6 +11,6 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
interface AssistedNodeFactory<NODE : Node> {
fun interface AssistedNodeFactory<NODE : Node> {
fun create(buildContext: BuildContext, plugins: List<Plugin>): NODE
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.architecture
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.coroutines.TimeoutCancellationException
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
@ -159,16 +160,19 @@ suspend inline fun <T> runUpdatingState(
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
}
state.value = AsyncAction.Loading
return resultBlock().fold(
return try {
resultBlock()
} catch (e: TimeoutCancellationException) {
state.value = AsyncAction.Failure(errorTransform(e))
throw e
}.fold(
onSuccess = {
state.value = AsyncAction.Success(it)
Result.success(it)
},
onFailure = {
val error = errorTransform(it)
state.value = AsyncAction.Failure(
error = error,
)
state.value = AsyncAction.Failure(error)
Result.failure(error)
}
)

View file

@ -10,34 +10,30 @@ package io.element.android.libraries.architecture
import android.content.Context
import android.content.ContextWrapper
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.DependencyInjectionGraphOwner
inline fun <reified T : Any> Node.optionalBindings() = optionalBindings(T::class.java)
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
fun <T : Any> Context.bindings(klass: Class<T>): T {
// search dagger components in the context hierarchy
// search the components in the dependency injection graph
return generateSequence(this) { (it as? ContextWrapper)?.baseContext }
.plus(applicationContext)
.filterIsInstance<DaggerComponentOwner>()
.map { it.daggerComponent }
.flatMap { if (it is Collection<*>) it else listOf(it) }
.filterIsInstance<DependencyInjectionGraphOwner>()
.map { it.graph }
.flatMap { it as? Collection<*> ?: listOf(it) }
.filterIsInstance(klass)
.firstOrNull()
?: error("Unable to find bindings for ${klass.name}")
}
fun <T : Any> Node.optionalBindings(klass: Class<T>): T? {
// search dagger components in node hierarchy
fun <T : Any> Node.bindings(klass: Class<T>): T {
// search the components in the node hierarchy
return generateSequence(this, Node::parent)
.filterIsInstance<DaggerComponentOwner>()
.map { it.daggerComponent }
.flatMap { if (it is Collection<*>) it else listOf(it) }
.filterIsInstance<DependencyInjectionGraphOwner>()
.map { it.graph }
.flatMap { it as? Collection<*> ?: listOf(it) }
.filterIsInstance(klass)
.firstOrNull()
}
fun <T : Any> Node.bindings(klass: Class<T>): T {
return optionalBindings(klass) ?: error("Unable to find bindings for ${klass.name}")
?: error("Unable to find bindings for ${klass.name}")
}

View file

@ -18,6 +18,6 @@ interface FeatureEntryPoint
/**
* Can be used when the feature only exposes a simple node without the need of plugins.
*/
interface SimpleFeatureEntryPoint : FeatureEntryPoint {
fun interface SimpleFeatureEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
}

View file

@ -7,10 +7,11 @@
package io.element.android.libraries.architecture
import android.content.Context
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.Multibinds
import kotlin.reflect.KClass
inline fun <reified N : Node> Node.createNode(
buildContext: BuildContext,
@ -20,23 +21,15 @@ inline fun <reified N : Node> Node.createNode(
return bindings.createNode(buildContext, plugins)
}
inline fun <reified N : Node> Context.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
): N {
val bindings: NodeFactoriesBindings = bindings()
return bindings.createNode(buildContext, plugins)
}
inline fun <reified N : Node> NodeFactoriesBindings.createNode(
buildContext: BuildContext,
plugins: List<Plugin> = emptyList()
plugins: List<Plugin>,
): N {
val nodeClass = N::class.java
val nodeClass = N::class
val nodeFactoryMap = nodeFactories()
// Note to developers: If you got the error below, make sure to build again after
// clearing the cache (sometimes several times) to let Dagger generate the NodeFactory.
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.")
// clearing the cache (sometimes several times) to let codegen generate the NodeFactory.
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.java.name}.")
@Suppress("UNCHECKED_CAST")
val castedNodeFactory = nodeFactory as? AssistedNodeFactory<N>
@ -44,6 +37,7 @@ inline fun <reified N : Node> NodeFactoriesBindings.createNode(
return node as N
}
interface NodeFactoriesBindings {
fun nodeFactories(): Map<Class<out Node>, AssistedNodeFactory<*>>
fun interface NodeFactoriesBindings {
@Multibinds
fun nodeFactories(): Map<KClass<out Node>, AssistedNodeFactory<*>>
}

View file

@ -8,10 +8,10 @@
package io.element.android.libraries.architecture
import com.bumble.appyx.core.node.Node
import dagger.MapKey
import dev.zacsweers.metro.MapKey
import kotlin.reflect.KClass
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@MapKey
annotation class NodeKey(val value: KClass<out Node>)

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

@ -0,0 +1,42 @@
/*
* 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
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class AsyncActionTest {
@Test
fun `updates state on timeout`() = runTest {
val state: MutableState<AsyncAction<Int>> = mutableStateOf(AsyncAction.Uninitialized)
val timeoutMillis = 500L
val operationTimeMillis = 1000L
try {
runUpdatingState(state = state) {
withTimeout(timeoutMillis.milliseconds) {
delay(operationTimeMillis)
}
Result.success(0)
}
fail("Expected TimeoutCancellationException, but nothing was thrown")
} catch (e: TimeoutCancellationException) {
assertTrue(state.value.isFailure())
assertSame(e, state.value.errorOrNull())
}
}
}

View file

@ -1,4 +1,4 @@
import extension.setupAnvil
import extension.setupDependencyInjection
/*
* Copyright 2025 New Vector Ltd.
@ -14,12 +14,11 @@ android {
namespace = "io.element.android.libraries.audio.impl"
}
setupAnvil()
setupDependencyInjection()
dependencies {
api(projects.libraries.audio.api)
implementation(libs.androidx.corektx)
implementation(libs.dagger)
implementation(projects.libraries.di)
}

View file

@ -13,15 +13,16 @@ import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
import io.element.android.libraries.di.annotations.ApplicationContext
@ContributesBinding(AppScope::class)
class DefaultAudioFocus @Inject constructor(
@Inject
class DefaultAudioFocus(
@ApplicationContext private val context: Context,
) : AudioFocus {
private val audioManager = requireNotNull(context.getSystemService<AudioManager>())

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,4 +1,5 @@
import extension.setupAnvil
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -15,13 +16,11 @@ android {
namespace = "io.element.android.libraries.cryptography.impl"
}
setupAnvil()
setupDependencyInjection()
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.di)
api(projects.libraries.cryptography.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}

View file

@ -7,21 +7,22 @@
package io.element.android.libraries.cryptography.impl
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.di.AppScope
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
/**
* Default implementation of [EncryptionDecryptionService] using AES encryption.
*/
@ContributesBinding(AppScope::class)
class AESEncryptionDecryptionService @Inject constructor() : EncryptionDecryptionService {
@Inject
class AESEncryptionDecryptionService : EncryptionDecryptionService {
override fun createEncryptionCipher(key: SecretKey): Cipher {
return Cipher.getInstance(AESEncryptionSpecs.CIPHER_TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, key)

View file

@ -7,16 +7,16 @@
package io.element.android.libraries.cryptography.impl
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import java.security.KeyStore
internal const val ANDROID_KEYSTORE = "AndroidKeyStore"
@ContributesTo(AppScope::class)
@Module
@BindingContainer
object CryptographyModule {
@Provides
fun providesAndroidKeyStore(): KeyStore {

View file

@ -10,23 +10,24 @@ package io.element.android.libraries.cryptography.impl
import android.annotation.SuppressLint
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.cryptography.api.AESEncryptionSpecs
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope
import timber.log.Timber
import java.security.KeyStore
import java.security.KeyStoreException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.inject.Inject
/**
* Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys.
* The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode.
*/
@ContributesBinding(AppScope::class)
class KeyStoreSecretKeyRepository @Inject constructor(
@Inject
class KeyStoreSecretKeyRepository(
private val keyStore: KeyStore,
) : SecretKeyRepository {
// False positive lint issue

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
*
@ -13,7 +15,6 @@ android {
namespace = "io.element.android.libraries.dateformatter.api"
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testCommonDependencies(libs)
}
}

View file

@ -1,4 +1,5 @@
import extension.setupAnvil
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -11,7 +12,7 @@ plugins {
id("io.element.android-compose-library")
}
setupAnvil()
setupDependencyInjection()
android {
namespace = "io.element.android.libraries.dateformatter.impl"
@ -31,7 +32,6 @@ android {
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
@ -41,13 +41,8 @@ android {
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
}
}

View file

@ -7,10 +7,10 @@
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.safeCapitalize
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface DateFormatterDay {
fun format(
@ -20,7 +20,8 @@ interface DateFormatterDay {
}
@ContributesBinding(AppScope::class)
class DefaultDateFormatterDay @Inject constructor(
@Inject
class DefaultDateFormatterDay(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : DateFormatterDay {

View file

@ -7,10 +7,11 @@
package io.element.android.libraries.dateformatter.impl
import dev.zacsweers.metro.Inject
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class DateFormatterFull @Inject constructor(
@Inject
class DateFormatterFull(
private val stringProvider: StringProvider,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,

View file

@ -7,11 +7,12 @@
package io.element.android.libraries.dateformatter.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.extensions.safeCapitalize
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class DateFormatterMonth @Inject constructor(
@Inject
class DateFormatterMonth(
private val stringProvider: StringProvider,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,

View file

@ -7,9 +7,10 @@
package io.element.android.libraries.dateformatter.impl
import javax.inject.Inject
import dev.zacsweers.metro.Inject
class DateFormatterTime @Inject constructor(
@Inject
class DateFormatterTime(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) {

View file

@ -7,9 +7,10 @@
package io.element.android.libraries.dateformatter.impl
import javax.inject.Inject
import dev.zacsweers.metro.Inject
class DateFormatterTimeOnly @Inject constructor(
@Inject
class DateFormatterTimeOnly(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) {

View file

@ -8,8 +8,9 @@
package io.element.android.libraries.dateformatter.impl
import android.text.format.DateUtils
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
@ -17,12 +18,12 @@ import kotlinx.datetime.toJavaLocalDateTime
import timber.log.Timber
import java.time.Period
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlin.time.Clock
@SingleIn(AppScope::class)
class DateFormatters @Inject constructor(
@Inject
class DateFormatters(
localeChangeObserver: LocaleChangeObserver,
private val clock: Clock,
private val timeZoneProvider: TimezoneProvider,

View file

@ -7,14 +7,15 @@
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultDateFormatter @Inject constructor(
@Inject
class DefaultDateFormatter(
private val dateFormatterFull: DateFormatterFull,
private val dateFormatterMonth: DateFormatterMonth,
private val dateFormatterDay: DateFormatterDay,

View file

@ -7,13 +7,14 @@
package io.element.android.libraries.dateformatter.impl
import dev.zacsweers.metro.Inject
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toLocalDateTime
import javax.inject.Inject
import kotlin.time.Clock
import kotlin.time.Instant
class LocalDateTimeProvider @Inject constructor(
@Inject
class LocalDateTimeProvider(
private val clock: Clock,
private val timezoneProvider: TimezoneProvider,
) {

View file

@ -12,11 +12,11 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
fun interface LocaleChangeObserver {
fun addListener(listener: LocaleChangeListener)
@ -28,7 +28,8 @@ interface LocaleChangeListener {
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLocaleChangeObserver @Inject constructor(
@Inject
class DefaultLocaleChangeObserver(
@ApplicationContext private val context: Context,
) : LocaleChangeObserver {
init {

View file

@ -7,16 +7,16 @@
package io.element.android.libraries.dateformatter.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.dateformatter.impl.TimezoneProvider
import io.element.android.libraries.di.AppScope
import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlin.time.Clock
@Module
@BindingContainer
@ContributesTo(AppScope::class)
object DateFormatterModule {
@Provides

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

@ -0,0 +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">"%2$s 에 %1$s"</string>
<string name="common_date_this_month">"이번 달"</string>
</resources>

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="common_date_date_at_time">"%1$s la %2$s"</string>
<string name="common_date_this_month">"Luna aceasta"</string>
</resources>

View file

@ -4,7 +4,14 @@
* 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")
}
package io.element.android.libraries.di
android {
namespace = "io.element.android.libraries.deeplink.api"
}
abstract class AppScope private constructor()
dependencies {
implementation(projects.libraries.matrix.api)
}

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.deeplink.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
fun interface DeepLinkCreator {
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.deeplink
package io.element.android.libraries.deeplink.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId

View file

@ -0,0 +1,14 @@
/*
* 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.deeplink.api
import android.content.Intent
fun interface DeeplinkParser {
fun getFromIntent(intent: Intent): DeeplinkData?
}

View file

@ -0,0 +1,14 @@
/*
* 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.deeplink.api.usecase
import android.app.Activity
fun interface InviteFriendsUseCase {
fun execute(activity: Activity)
}

View file

@ -1,4 +1,5 @@
import extension.setupAnvil
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
@ -6,19 +7,20 @@ import extension.setupAnvil
* 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.deeplink"
namespace = "io.element.android.libraries.deeplink.impl"
}
setupAnvil()
setupDependencyInjection()
dependencies {
api(projects.libraries.deeplink.api)
implementation(projects.libraries.di)
implementation(libs.dagger)
implementation(libs.androidx.corektx)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
@ -27,9 +29,6 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.deeplink
package io.element.android.libraries.deeplink.impl
internal const val SCHEME = "elementx"
internal const val HOST = "open"

View file

@ -1,19 +1,24 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* 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.deeplink
package io.element.android.libraries.deeplink.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.deeplink.api.DeepLinkCreator
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import javax.inject.Inject
class DeepLinkCreator @Inject constructor() {
fun room(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
@ContributesBinding(AppScope::class)
@Inject
class DefaultDeepLinkCreator : DeepLinkCreator {
override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String {
return buildString {
append("$SCHEME://$HOST/")
append(sessionId.value)

View file

@ -1,21 +1,27 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* 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.deeplink
package io.element.android.libraries.deeplink.impl
import android.content.Intent
import android.net.Uri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import javax.inject.Inject
class DeeplinkParser @Inject constructor() {
fun getFromIntent(intent: Intent): DeeplinkData? {
@ContributesBinding(AppScope::class)
@Inject
class DefaultDeeplinkParser : DeeplinkParser {
override fun getFromIntent(intent: Intent): DeeplinkData? {
return intent
.takeIf { it.action == Intent.ACTION_VIEW }
?.data

View file

@ -1,30 +1,35 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* 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.deeplink.usecase
package io.element.android.libraries.deeplink.impl.usecase
import android.app.Activity
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
import javax.inject.Inject
import io.element.android.libraries.androidutils.R as AndroidUtilsR
class InviteFriendsUseCase @Inject constructor(
@ContributesBinding(SessionScope::class)
@Inject
class DefaultInviteFriendsUseCase(
private val stringProvider: StringProvider,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
private val permalinkBuilder: PermalinkBuilder,
) {
fun execute(activity: Activity) {
) : InviteFriendsUseCase {
override fun execute(activity: Activity) {
val permalinkResult = permalinkBuilder.permalinkForUser(matrixClient.sessionId)
permalinkResult.fold(
onSuccess = { permalink ->

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.deeplink
package io.element.android.libraries.deeplink.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -13,15 +13,15 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import org.junit.Test
class DeepLinkCreatorTest {
class DefaultDeepLinkCreatorTest {
@Test
fun room() {
val sut = DeepLinkCreator()
assertThat(sut.room(A_SESSION_ID, null, null))
fun create() {
val sut = DefaultDeepLinkCreator()
assertThat(sut.create(A_SESSION_ID, null, null))
.isEqualTo("elementx://open/@alice:server.org")
assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, null))
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain")
assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
}
}

View file

@ -5,11 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.deeplink
package io.element.android.libraries.deeplink.impl
import android.content.Intent
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.deeplink.api.DeeplinkData
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_THREAD_ID
@ -19,7 +20,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DeeplinkParserTest {
class DefaultDeeplinkParserTest {
companion object {
const val A_URI =
"elementx://open/@alice:server.org"
@ -29,10 +30,9 @@ class DeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
}
private val sut = DeeplinkParser()
@Test
fun `nominal cases`() {
val sut = DefaultDeeplinkParser()
assertThat(sut.getFromIntent(createIntent(A_URI)))
.isEqualTo(DeeplinkData.Root(A_SESSION_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM)))
@ -43,7 +43,7 @@ class DeeplinkParserTest {
@Test
fun `error cases`() {
val sut = DeeplinkParser()
val sut = DefaultDeeplinkParser()
// Bad scheme
assertThat(sut.getFromIntent(createIntent("x://open/@alice:server.org"))).isNull()
// Bad host

View file

@ -1,3 +1,5 @@
import extension.testCommonDependencies
/*
* Copyright 2022-2024 New Vector Ltd.
*
@ -42,10 +44,6 @@ android {
ksp(libs.showkase.processor)
implementation(libs.showkase)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testCommonDependencies(libs)
}
}

View file

@ -15,14 +15,18 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewDescriptionAtom(description: String, modifier: Modifier = Modifier) {
fun RoomPreviewDescriptionAtom(
description: String,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
) {
Text(
modifier = modifier,
text = description,
style = ElementTheme.typography.fontBodySmRegular,
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
maxLines = 3,
color = ElementTheme.colors.textPrimary,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}

View file

@ -18,7 +18,7 @@ fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
)

View file

@ -23,7 +23,7 @@ fun RoomPreviewTitleAtom(
Text(
modifier = modifier,
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
fontStyle = fontStyle,
color = ElementTheme.colors.textPrimary,

View file

@ -15,6 +15,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@ -28,9 +30,13 @@ fun UnreadIndicatorAtom(
size: Dp = 12.dp,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
contentDescription: String? = null,
) {
Box(
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.size(size)
.clip(CircleShape)
.background(if (isVisible) color else Color.Transparent)

View file

@ -0,0 +1,46 @@
/*
* 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.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InviteButtonsRowMolecule(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
modifier: Modifier = Modifier,
declineText: String = stringResource(CommonStrings.action_decline),
acceptText: String = stringResource(CommonStrings.action_accept),
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = declineText,
onClick = onDeclineClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
Button(
text = acceptText,
onClick = onAcceptClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}

View file

@ -25,8 +25,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewMembersCountMolecule(
memberCount: Long,
fun MembersCountMolecule(
memberCount: Int,
modifier: Modifier = Modifier,
) {
Row(
@ -51,13 +51,13 @@ fun RoomPreviewMembersCountMolecule(
@PreviewsDayNight
@Composable
internal fun RoomPreviewMembersCountMoleculePreview() = ElementPreview {
internal fun MembersCountMoleculePreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RoomPreviewMembersCountMolecule(memberCount = 1)
RoomPreviewMembersCountMolecule(memberCount = 888)
RoomPreviewMembersCountMolecule(memberCount = 123_456)
MembersCountMolecule(memberCount = 1)
MembersCountMolecule(memberCount = 888)
MembersCountMolecule(memberCount = 123_456)
}
}

View file

@ -34,14 +34,13 @@ fun RoomPreviewOrganism(
title()
Spacer(modifier = Modifier.height(8.dp))
subtitle()
Spacer(modifier = Modifier.height(8.dp))
if (memberCount != null) {
Spacer(modifier = Modifier.height(8.dp))
memberCount()
}
Spacer(modifier = Modifier.height(8.dp))
if (description != null) {
Spacer(modifier = Modifier.height(16.dp))
description()
}
Spacer(modifier = Modifier.height(24.dp))
}
}

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
*
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
* @param modifier Classical modifier.
* @param renderBackground whether to render the background image or not.
* @param contentAlignment horizontal alignment of the contents.
* @param footer optional footer.
* @param content main content.
@ -38,6 +39,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun OnBoardingPage(
modifier: Modifier = Modifier,
renderBackground: Boolean = true,
contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
@ -47,13 +49,15 @@ fun OnBoardingPage(
.fillMaxSize()
) {
// BG
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = R.drawable.onboarding_bg),
contentScale = ContentScale.Crop,
contentDescription = null,
)
if (renderBackground) {
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = R.drawable.onboarding_bg),
contentScale = ContentScale.Crop,
contentDescription = null,
)
}
Column(
modifier = Modifier
.fillMaxSize()

View file

@ -60,7 +60,7 @@ import kotlin.math.roundToInt
@Composable
fun ExpandableBottomSheetLayout(
sheetDragHandle: @Composable BoxScope.() -> Unit,
sheetDragHandle: @Composable BoxScope.(toggleAction: () -> Unit) -> Unit,
bottomSheetContent: @Composable ColumnScope.() -> Unit,
state: ExpandableBottomSheetLayoutState,
maxBottomSheetContentHeight: Dp,
@ -152,7 +152,19 @@ fun ExpandableBottomSheetLayout(
}
) {
Box(Modifier.fillMaxWidth()) {
sheetDragHandle()
sheetDragHandle {
coroutineScope.launch {
val destination = if (state.position == ExpandableBottomSheetLayoutState.Position.EXPANDED) {
state.internalPosition = ExpandableBottomSheetLayoutState.Position.COLLAPSED
minBottomContentHeightPx.toFloat()
} else {
state.internalPosition = ExpandableBottomSheetLayoutState.Position.EXPANDED
calculatedMaxBottomContentHeightPx.toFloat()
}
animatable.snapTo(currentBottomContentHeightPx.toFloat())
animatable.animateTo(destination)
}
}
}
bottomSheetContent()
}

View file

@ -38,7 +38,7 @@ class ExpandableBottomSheetLayoutState {
/**
* The current position of the bottom sheet layout.
*/
val position = internalPosition
val position get() = internalPosition
/**
* The percentage of the bottom sheet layout that is currently being dragged.

View file

@ -38,6 +38,18 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
/**
* A progress dialog, with a spinner, and optional text content.
*
* @param modifier
* @param text Optional text to show under the spinner.
* @param type
* @param properties
* @param showCancelButton
* @param onDismissRequest
* @param content Optional additional content to show under the spinner, and above the cancel button (if shown). If both `text` and `content` are supplied,
* `text` is shown above `content`.
*/
@Composable
fun ProgressDialog(
modifier: Modifier = Modifier,
@ -46,6 +58,7 @@ fun ProgressDialog(
properties: DialogProperties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
showCancelButton: Boolean = false,
onDismissRequest: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
DisposableEffect(Unit) {
onDispose {
@ -75,7 +88,8 @@ fun ProgressDialog(
)
}
}
}
},
content,
)
}
}
@ -96,7 +110,8 @@ private fun ProgressDialogContent(
CircularProgressIndicator(
color = ElementTheme.colors.iconPrimary
)
}
},
content: @Composable () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
@ -118,6 +133,7 @@ private fun ProgressDialogContent(
color = ElementTheme.colors.textPrimary,
)
}
content()
if (showCancelButton) {
Spacer(modifier = Modifier.height(24.dp))
Box(
@ -138,7 +154,7 @@ private fun ProgressDialogContent(
@Composable
internal fun ProgressDialogContentPreview() = ElementThemedPreview {
DialogPreview {
ProgressDialogContent(text = "test dialog content", showCancelButton = true)
ProgressDialogContent(text = "test dialog content", showCancelButton = true, content = {})
}
}
@ -147,3 +163,34 @@ internal fun ProgressDialogContentPreview() = ElementThemedPreview {
internal fun ProgressDialogPreview() = ElementPreview {
ProgressDialog(text = "test dialog content", showCancelButton = true)
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithContentPreview() = ElementPreview {
ProgressDialog(showCancelButton = true) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Heading",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Subtext",
color = ElementTheme.colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithTextAndContentPreview() = ElementPreview {
ProgressDialog(text = "Text Content") {
Text(
text = "blah blah",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
}
}

View file

@ -16,8 +16,8 @@ open class AvatarDataProvider : PreviewParameterProvider<AvatarData> {
.map {
sequenceOf(
anAvatarData(size = it),
anAvatarData(size = it).copy(name = null),
anAvatarData(size = it).copy(url = "aUrl"),
anAvatarData(size = it, name = null),
anAvatarData(size = it, url = "aUrl"),
)
}
.flatten()
@ -26,10 +26,12 @@ open class AvatarDataProvider : PreviewParameterProvider<AvatarData> {
fun anAvatarData(
// Let's the id not start with a 'a'.
id: String = "@id_of_alice:server.org",
name: String = "Alice",
name: String? = "Alice",
url: String? = null,
size: AvatarSize = AvatarSize.RoomListItem,
) = AvatarData(
id = id,
name = name,
url = url,
size = size,
)

View file

@ -0,0 +1,173 @@
/*
* Copyright 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.designsystem.components.avatar
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.internal.OverlapRatioProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Draw a row of avatars (they must all have the same size), from start to end.
* @param avatarDataList the avatars to render. Note: they will all be rendered, the caller may
* want to limit the list size
* @param avatarType the type of avatars to render
* @param modifier Jetpack Compose modifier
* @param overlapRatio the overlap ration. When 0f, avatars will render without overlap, when 1f
* only the first avatar will be visible
* @param lastOnTop if true, the last visible avatar will be rendered on top.
*/
@Composable
fun AvatarRow(
avatarDataList: ImmutableList<AvatarData>,
avatarType: AvatarType,
modifier: Modifier = Modifier,
overlapRatio: Float = 0.5f,
lastOnTop: Boolean = false,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier = modifier,
) {
val lastItemIndex = avatarDataList.size - 1
val avatarSize = avatarDataList.firstOrNull()?.size?.dp ?: return
val avatarSizePx = avatarSize.toPx()
avatarDataList
.let {
if (lastOnTop) {
it
} else {
it.reversed()
}
}
.forEachIndexed { index, avatarData ->
val startPadding = if (lastOnTop) {
avatarSize * (1 - overlapRatio) * index
} else {
avatarSize * (1 - overlapRatio) * (lastItemIndex - index)
}
Avatar(
modifier = Modifier
.padding(start = startPadding)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
// Draw content and clear the pixels for the avatar on the left (right in RTL) or when lastOnTop is true on
// the right (left in RTL).
drawContent()
if (index < lastItemIndex) {
val xOffset = if (isRtl == lastOnTop) {
avatarSizePx * (overlapRatio - 0.5f)
} else {
size.width - avatarSizePx * (overlapRatio - 0.5f)
}
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = size.height / 2,
),
radius = avatarSizePx / 2,
blendMode = BlendMode.Clear,
)
}
}
.size(size = avatarSize)
// Keep internal padding, it has the advantage to not reduce the size of the Avatar image,
// which is already small in our use case.
.padding(2.dp),
avatarData = avatarData,
avatarType = avatarType,
)
}
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
ElementPreview {
ContentToPreview(overlapRatio)
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowLastOnTopPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
ElementPreview {
ContentToPreview(
overlapRatio = overlapRatio,
lastOnTop = true,
)
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
ContentToPreview(overlapRatio)
}
}
}
@Composable
@PreviewsDayNight
internal fun AvatarRowLastOnTopRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
ContentToPreview(
overlapRatio = overlapRatio,
lastOnTop = true,
)
}
}
}
@Composable
private fun ContentToPreview(
overlapRatio: Float,
lastOnTop: Boolean = false,
) {
AvatarRow(
avatarDataList = listOf("A", "B", "C").map {
AvatarData(
id = it,
name = it,
size = AvatarSize.RoomListItem,
)
}.toImmutableList(),
avatarType = AvatarType.User,
overlapRatio = overlapRatio,
lastOnTop = lastOnTop,
)
}

View file

@ -14,9 +14,11 @@ enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
IncomingCall(140.dp),
RoomHeader(96.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
SpaceListItem(52.dp),
RoomSelectRoomListItem(36.dp),
UserPreference(56.dp),
@ -32,6 +34,7 @@ enum class AvatarSize(val dp: Dp) {
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
TimelineThreadLatestEventSender(24.dp),
ComposerAlert(32.dp),
@ -63,4 +66,13 @@ enum class AvatarSize(val dp: Dp) {
DmCreationConfirmation(64.dp),
UserVerification(52.dp),
OrganizationHeader(64.dp),
SpaceHeader(64.dp),
RoomPreviewHeader(64.dp),
RoomPreviewInviter(56.dp),
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
AccountItem(32.dp),
}

View file

@ -0,0 +1,20 @@
/*
* 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.designsystem.components.avatar.internal
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class OverlapRatioProvider : PreviewParameterProvider<Float> {
override val values: Sequence<Float> = sequenceOf(
0f,
0.25f,
0.5f,
0.75f,
1f
)
}

View file

@ -142,7 +142,7 @@ internal fun SimpleAlertDialogContent(
Text(
text = titleText,
style = ElementTheme.typography.fontHeadingSmMedium,
textAlign = TextAlign.Center,
textAlign = if (icon != null) TextAlign.Center else TextAlign.Start,
)
}
},
@ -510,3 +510,43 @@ internal fun DialogWithThirdButtonPreview() {
}
}
}
@Preview(group = PreviewGroup.Dialogs, name = "Dialog with a very long title")
@Composable
@Suppress("MaxLineLength")
internal fun DialogWithVeryLongTitlePreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
SimpleAlertDialogContent(
title = "Dialog Title that takes more than one line",
content = "A dialog is a type of modal window that appears in front of app content to provide critical information," +
" or prompt for a decision to be made. Learn more",
submitText = "OK",
onSubmitClick = {},
)
}
}
}
@Preview(group = PreviewGroup.Dialogs, name = "Dialog with a very long title and icon")
@Composable
@Suppress("MaxLineLength")
internal fun DialogWithVeryLongTitleAndIconPreview() {
ElementThemedPreview(showBackground = false) {
DialogPreview {
SimpleAlertDialogContent(
icon = {
Icon(
imageVector = CompoundIcons.NotificationsSolid(),
contentDescription = null
)
},
title = "Dialog Title that takes more than one line",
content = "A dialog is a type of modal window that appears in front of app content to provide critical information," +
" or prompt for a decision to be made. Learn more",
submitText = "OK",
onSubmitClick = {},
)
}
}
}

View file

@ -23,7 +23,10 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@ -67,16 +70,19 @@ fun <T> SearchBar(
) {
val focusManager = LocalFocusManager.current
if (!active) {
onQueryChange("")
focusManager.clearFocus()
val updatedOnQueryChange by rememberUpdatedState(onQueryChange)
LaunchedEffect(active) {
if (!active) {
updatedOnQueryChange("")
focusManager.clearFocus()
}
}
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = onQueryChange,
onQueryChange = updatedOnQueryChange,
onSearch = { focusManager.clearFocus() },
expanded = active,
onExpandedChange = onActiveChange,

View file

@ -11,5 +11,5 @@ plugins {
}
dependencies {
api(libs.inject)
api(libs.metro.runtime)
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2022-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.di
import dev.zacsweers.metro.Qualifier
/**
* Qualifies a [File] object which represents the application base directory.
*/
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FIELD,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.TYPE,
)
public annotation class BaseDirectory

View file

@ -7,7 +7,7 @@
package io.element.android.libraries.di
import javax.inject.Qualifier
import dev.zacsweers.metro.Qualifier
/**
* Qualifies a [File] object which represents the application cache directory.
@ -15,4 +15,13 @@ import javax.inject.Qualifier
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Qualifier
annotation class CacheDirectory
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FIELD,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.TYPE,
)
public annotation class CacheDirectory

View file

@ -8,10 +8,10 @@
package io.element.android.libraries.di
/**
* A [DaggerComponentOwner] is anything that "owns" a Dagger Component.
* A [DependencyInjectionGraphOwner] is anything that "owns" a DI Graph.
*
*/
interface DaggerComponentOwner {
/** This is either a component, or a list of components. */
val daggerComponent: Any
interface DependencyInjectionGraphOwner {
/** This is either a graph, or a list of graphs. */
val graph: Any
}

View file

@ -1,15 +0,0 @@
/*
* Copyright 2022-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.di
import javax.inject.Scope
import kotlin.reflect.KClass
@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class SingleIn(val clazz: KClass<*>)

View file

@ -7,7 +7,7 @@
package io.element.android.libraries.di.annotations
import javax.inject.Qualifier
import dev.zacsweers.metro.Qualifier
/**
* Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for the application.

View file

@ -1,13 +1,13 @@
/*
* Copyright 2022-2024 New Vector Ltd.
* 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.di
package io.element.android.libraries.di.annotations
import javax.inject.Qualifier
import dev.zacsweers.metro.Qualifier
/**
* Qualifies a [Context] object that represents the application context.

View file

@ -7,7 +7,7 @@
package io.element.android.libraries.di.annotations
import javax.inject.Qualifier
import dev.zacsweers.metro.Qualifier
/**
* Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for an active session.

View file

@ -7,8 +7,19 @@
package io.element.android.libraries.eventformatter.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
interface TimelineEventFormatter {
fun format(event: EventTimelineItem): CharSequence?
fun format(event: EventTimelineItem): CharSequence? {
return format(
content = event.content,
isOutgoing = event.isOwn,
sender = event.sender,
senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
)
}
fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence?
}

View file

@ -1,4 +1,5 @@
import extension.setupAnvil
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright 2023, 2024 New Vector Ltd.
@ -21,7 +22,7 @@ android {
}
}
setupAnvil()
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
@ -32,9 +33,7 @@ dependencies {
implementation(projects.services.toolbox.api)
api(projects.libraries.eventformatter.api)
testCommonDependencies(libs)
testImplementation(projects.services.toolbox.impl)
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

View file

@ -9,7 +9,8 @@ package io.element.android.libraries.eventformatter.impl
import androidx.annotation.StringRes
import androidx.compose.ui.text.AnnotatedString
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@ -35,10 +36,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultPinnedMessagesBannerFormatter @Inject constructor(
@Inject
class DefaultPinnedMessagesBannerFormatter(
private val sp: StringProvider,
private val permalinkParser: PermalinkParser,
) : PinnedMessagesBannerFormatter {

View file

@ -7,7 +7,8 @@
package io.element.android.libraries.eventformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
@ -42,10 +43,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getDisambigua
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultRoomLastMessageFormatter @Inject constructor(
@Inject
class DefaultRoomLastMessageFormatter(
private val sp: StringProvider,
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
private val profileChangeContentFormatter: ProfileChangeContentFormatter,

View file

@ -7,12 +7,15 @@
package io.element.android.libraries.eventformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -29,10 +32,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnknownConten
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultTimelineEventFormatter @Inject constructor(
@Inject
class DefaultTimelineEventFormatter(
private val sp: StringProvider,
private val buildMeta: BuildMeta,
private val roomMembershipContentFormatter: RoomMembershipContentFormatter,
@ -42,12 +45,16 @@ class DefaultTimelineEventFormatter @Inject constructor(
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
return format(event.content, isOutgoing, event.sender, senderDisambiguatedDisplayName)
}
override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? {
return when (content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
profileChangeContentFormatter.format(content, sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
@ -65,7 +72,7 @@ class DefaultTimelineEventFormatter @Inject constructor(
is FailedToParseStateContent,
is UnknownContent -> {
if (buildMeta.isDebuggable) {
error("You should not use this formatter for this event: $event")
error("You should not use this formatter for this event content: $content")
}
sp.getString(CommonStrings.common_unsupported_event)
}

View file

@ -7,12 +7,13 @@
package io.element.android.libraries.eventformatter.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class ProfileChangeContentFormatter @Inject constructor(
@Inject
class ProfileChangeContentFormatter(
private val sp: StringProvider,
) {
fun format(

View file

@ -7,14 +7,15 @@
package io.element.android.libraries.eventformatter.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
import javax.inject.Inject
class RoomMembershipContentFormatter @Inject constructor(
@Inject
class RoomMembershipContentFormatter(
private val matrixClient: MatrixClient,
private val sp: StringProvider,
) {

View file

@ -7,15 +7,16 @@
package io.element.android.libraries.eventformatter.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
import javax.inject.Inject
class StateContentFormatter @Inject constructor(
@Inject
class StateContentFormatter(
private val sp: StringProvider,
) {
fun format(

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

@ -2,72 +2,72 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(Avatar wurde auch geändert)"</string>
<string name="state_event_avatar_url_changed">"%1$s hat den Avatar geändert"</string>
<string name="state_event_avatar_url_changed_by_you">"Sie änderten ihren Avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Du hast deinen Avatar geändert"</string>
<string name="state_event_demoted_to_member">"%1$s wurde zum Mitglied herabgestuft"</string>
<string name="state_event_demoted_to_moderator">"%1$s wurde zum Moderator herabgestuft"</string>
<string name="state_event_display_name_changed_from">"%1$s hat den Anzeigenamen von %2$s auf %3$s geändert"</string>
<string name="state_event_display_name_changed_from_by_you">"Du hast deinen Anzeigenamen von %1$s auf %2$s geändert"</string>
<string name="state_event_display_name_removed">"%1$s hat den Anzeigenamen entfernt (war %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Sie haben Ihren Anzeigenamen entfernt (er lautete %1$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (war %1$s)"</string>
<string name="state_event_display_name_set">"%1$s hat den Anzeigenamen auf %2$s geändert"</string>
<string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen zu %1$s geändert"</string>
<string name="state_event_promoted_to_administrator">"%1$s ist jetzt Administrator*in"</string>
<string name="state_event_promoted_to_administrator">"%1$s ist jetzt Admin"</string>
<string name="state_event_promoted_to_moderator">"%1$s ist jetzt Moderator*in"</string>
<string name="state_event_room_avatar_changed">"%1$s hat den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_changed_by_you">"Dir haben den Zimmer-Avatar geändert"</string>
<string name="state_event_room_avatar_removed">"%1$s hat den Raum-Avatar entfernt"</string>
<string name="state_event_room_avatar_removed_by_you">"Sie haben den Raum-Avatar entfernt"</string>
<string name="state_event_room_avatar_changed">"%1$s hat den Chat -Avatar geändert"</string>
<string name="state_event_room_avatar_changed_by_you">"Du hast den Chat-Avatar geändert"</string>
<string name="state_event_room_avatar_removed">"%1$s hat den Chat-Avatar entfernt"</string>
<string name="state_event_room_avatar_removed_by_you">"Du hast den Chat-Avatar entfernt"</string>
<string name="state_event_room_ban">"%1$s hat %2$s gesperrt"</string>
<string name="state_event_room_ban_by_you">"Sie haben %1$s gesperrt"</string>
<string name="state_event_room_ban_by_you_with_reason">"Sie haben %1$s gesperrt: %2$s"</string>
<string name="state_event_room_ban_by_you">"Du hast %1$s gesperrt"</string>
<string name="state_event_room_ban_by_you_with_reason">"Du hast %1$s gesperrt: %2$s"</string>
<string name="state_event_room_ban_with_reason">"%1$s sperrte %2$s: %3$s"</string>
<string name="state_event_room_created">"%1$s hat den Raum erstellt"</string>
<string name="state_event_room_created_by_you">"Sie erstellten den Chatroom"</string>
<string name="state_event_room_created">"%1$s hat den Chat erstellt"</string>
<string name="state_event_room_created_by_you">"Du hast den Chat erstellt"</string>
<string name="state_event_room_invite">"%1$s hat %2$s eingeladen"</string>
<string name="state_event_room_invite_accepted">"%1$s hat die Einladung angenommen"</string>
<string name="state_event_room_invite_accepted_by_you">"Sie haben die Einladung angenommen"</string>
<string name="state_event_room_invite_by_you">"Sie luden %1$s ein"</string>
<string name="state_event_room_invite_accepted_by_you">"Du hast die Einladung angenommen"</string>
<string name="state_event_room_invite_by_you">"Du hast %1$s eingeladen"</string>
<string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string>
<string name="state_event_room_join">"%1$s hat den Raum betreten"</string>
<string name="state_event_room_join_by_you">"Sie sind dem Raum beigetreten"</string>
<string name="state_event_room_knock">"%1$s beantragt den Beitritt"</string>
<string name="state_event_room_join">"%1$s ist dem Chat beigetreten"</string>
<string name="state_event_room_join_by_you">"Du bist dem Chat beigetreten"</string>
<string name="state_event_room_knock">"%1$s fragt den Beitritt an"</string>
<string name="state_event_room_knock_accepted">"%1$s hat %2$s den Beitritt erlaubt"</string>
<string name="state_event_room_knock_accepted_by_you">"Sie genehmigten die Beitrittsanfrage für %1$s"</string>
<string name="state_event_room_knock_by_you">"Sie haben um Beitritt gebeten"</string>
<string name="state_event_room_knock_accepted_by_you">"Du hast %1$s den Beitritt erlaubt"</string>
<string name="state_event_room_knock_by_you">"Du hast angefragt beizutreten"</string>
<string name="state_event_room_knock_denied">"%1$s hat die Beitrittsanfrage von %2$s abgelehnt"</string>
<string name="state_event_room_knock_denied_by_you">"Sie haben die Beitrittsanfrage für %1$s abgelehnt"</string>
<string name="state_event_room_knock_denied_by_you">"Du hast die Beitrittsanfrage von %1$s abgelehnt"</string>
<string name="state_event_room_knock_denied_you">"%1$s hat deine Beitrittsanfrage abgelehnt"</string>
<string name="state_event_room_knock_retracted">"%1$s ist nicht mehr an einem Beitritt interessiert"</string>
<string name="state_event_room_knock_retracted_by_you">"Sie haben ihre Beitrittsanfrage widerrufen"</string>
<string name="state_event_room_leave">"%1$s hat den Raum verlassen"</string>
<string name="state_event_room_leave_by_you">"Sie verließen den Raum"</string>
<string name="state_event_room_name_changed">"%1$s hat den Raum-Namen geändert in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Sie haben den Raumnamen geändert in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s hat den Raum-Namen entfernt"</string>
<string name="state_event_room_name_removed_by_you">"Sie haben den Raumnamen entfernt"</string>
<string name="state_event_room_knock_retracted_by_you">"Du hast deine Beitrittsanfrage abgebrochen"</string>
<string name="state_event_room_leave">"%1$s hat den Chat verlassen"</string>
<string name="state_event_room_leave_by_you">"Du hast den Chat verlassen"</string>
<string name="state_event_room_name_changed">"%1$s hat den Chat-Namen geändert in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Du hast den Chat-Namen geändert in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s hat den Chat-Namen entfernt"</string>
<string name="state_event_room_name_removed_by_you">"Du hast den Chat-Namen entfernt"</string>
<string name="state_event_room_none">"%1$s hat keine Änderungen vorgenommen"</string>
<string name="state_event_room_none_by_you">"Sie haben keine Änderungen vorgenommen"</string>
<string name="state_event_room_none_by_you">"Du hast keine Änderungen vorgenommen"</string>
<string name="state_event_room_pinned_events_changed">"%1$s hat die fixierten Nachrichten geändert"</string>
<string name="state_event_room_pinned_events_changed_by_you">"Sie haben die angehefteten Nachrichten geändert."</string>
<string name="state_event_room_pinned_events_changed_by_you">"Du hast die fixierten Nachrichten geändert"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s fixierte eine Nachricht"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"Sie haben eine Nachricht angeheftet."</string>
<string name="state_event_room_pinned_events_pinned_by_you">"Du hast eine Nachricht fixiert"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s löste eine Nachricht"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"Sie haben eine angeheftete Nachricht entfernt."</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"Du hast eine Nachricht gelöst"</string>
<string name="state_event_room_reject">"%1$s lehnte die Einladung ab"</string>
<string name="state_event_room_reject_by_you">"Sie lehnten die Einladung ab"</string>
<string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string>
<string name="state_event_room_remove">"%1$s hat %2$s entfernt"</string>
<string name="state_event_room_remove_by_you">"Sie haben %1$s entfernt"</string>
<string name="state_event_room_remove_by_you_with_reason">"Sie haben %1$s entfernt: %2$s"</string>
<string name="state_event_room_remove_by_you">"Du hast %1$s entfernt"</string>
<string name="state_event_room_remove_by_you_with_reason">"Du hast %1$s entfernt: %2$s"</string>
<string name="state_event_room_remove_with_reason">"%1$s entfernt %2$s: %3$s"</string>
<string name="state_event_room_third_party_invite">"%1$s hat %2$s eingeladen, den Raum zu beizutreten"</string>
<string name="state_event_room_third_party_invite_by_you">"Sie haben eine Einladung an %1$s gesendet, um dem Chatroom beizutreten."</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s hat die Einladung an %2$s zum Betreten des Raums zurückgezogen"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Sie haben die Einladung für %1$s widerrufen"</string>
<string name="state_event_room_third_party_invite">"%1$s hat %2$s eingeladen, den Chat zu beizutreten"</string>
<string name="state_event_room_third_party_invite_by_you">"Du hast eine Einladung an %1$s gesendet, dem Chat beizutreten"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s hat die Einladung an %2$s zum Beitritt des Chat zurückgezogen"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Du hast die Einladung an %1$s zum Beitritt des Chat zurückgezogen"</string>
<string name="state_event_room_topic_changed">"%1$s hat das Thema geändert in: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Sie haben das Thema geändert in:%1$s"</string>
<string name="state_event_room_topic_removed">"%1$s hat das Raum-Thema entfernt"</string>
<string name="state_event_room_topic_removed_by_you">"Sie haben das Raumthema entfernt"</string>
<string name="state_event_room_topic_changed_by_you">"Du hast das Thema geändert in: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s hat das Chat-Thema entfernt"</string>
<string name="state_event_room_topic_removed_by_you">"Du hast das Chat-Thema entfernt"</string>
<string name="state_event_room_unban">"%1$s hat die Sperre für %2$s aufgehoben"</string>
<string name="state_event_room_unban_by_you">"Sie haben die Sperre für %1$s aufgehoben"</string>
<string name="state_event_room_unban_by_you">"Du hast die Sperre für %1$s aufgehoben"</string>
<string name="state_event_room_unknown_membership_change">"%1$s hat eine unbekannte Änderung vorgenommen"</string>
</resources>

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(프로필 사진도 변경됨)"</string>
<string name="state_event_avatar_url_changed">"%1$s님이 프로필 사진을 변경함"</string>
<string name="state_event_avatar_url_changed_by_you">"프로필 사진을 변경함"</string>
<string name="state_event_demoted_to_member">"%1$s 회원으로 강등되었습니다"</string>
<string name="state_event_demoted_to_moderator">"%1$s 중재자로 강등되었습니다"</string>
<string name="state_event_display_name_changed_from">"%1$s님이 표시 이름을 %2$s에서 %3$s(으)로 변경했습니다."</string>
<string name="state_event_display_name_changed_from_by_you">"표시 이름을 %1$s에서 %2$s(으)로 변경했습니다"</string>
<string name="state_event_display_name_removed">"%1$s님이 표시 이름을 제거했습니다 (이전 이름 %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"표시 이름을 제거했습니다 (이전 이름 %1$s)"</string>
<string name="state_event_display_name_set">"%1$s님이 표시되는 이름을 %2$s(으)로 변경함"</string>
<string name="state_event_display_name_set_by_you">"%1$s(으)로 표시되는 이름을 변경함"</string>
<string name="state_event_promoted_to_administrator">"%1$s 는 관리자로 승진되었습니다"</string>
<string name="state_event_promoted_to_moderator">"%1$s 는 중재자로 승진되었습니다"</string>
<string name="state_event_room_avatar_changed">"%1$s님이 방 아바타를 변경함"</string>
<string name="state_event_room_avatar_changed_by_you">"방 아바타를 변경함"</string>
<string name="state_event_room_avatar_removed">"%1$s님이 방 아바타를 삭제함"</string>
<string name="state_event_room_avatar_removed_by_you">"방 아바타를 삭제함"</string>
<string name="state_event_room_ban">"%1$s님이 %2$s님을 차단함"</string>
<string name="state_event_room_ban_by_you">"%1$s님을 차단함"</string>
<string name="state_event_room_ban_by_you_with_reason">"당신은 차단했습니다 %1$s: %2$s"</string>
<string name="state_event_room_ban_with_reason">"%1$s 차단됨 %2$s: %3$s"</string>
<string name="state_event_room_created">"%1$s님이 방을 생성함"</string>
<string name="state_event_room_created_by_you">"방을 생성함"</string>
<string name="state_event_room_invite">"%1$s님이 %2$s님을 초대함"</string>
<string name="state_event_room_invite_accepted">"%1$s님이 초대를 수락함"</string>
<string name="state_event_room_invite_accepted_by_you">"초대를 수락함"</string>
<string name="state_event_room_invite_by_you">"%1$s님을 초대함"</string>
<string name="state_event_room_invite_you">"%1$s님으로부터 초대받음"</string>
<string name="state_event_room_join">"%1$s님이 방에 참석함"</string>
<string name="state_event_room_join_by_you">"방에 참석함"</string>
<string name="state_event_room_knock">"%1$s님이 참가를 요청함"</string>
<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>
<string name="state_event_room_name_changed_by_you">"방 이름을 변경함: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s님이 방 이름을 삭제함"</string>
<string name="state_event_room_name_removed_by_you">"방 이름을 삭제함"</string>
<string name="state_event_room_none">"%1$s 변경 사항 없음"</string>
<string name="state_event_room_none_by_you">"변경 사항이 없습니다."</string>
<string name="state_event_room_pinned_events_changed">"%1$s 고정된 메시지가 변경되었습니다."</string>
<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_remove_by_you_with_reason">"제거했습니다 %1$s :%2$s"</string>
<string name="state_event_room_remove_with_reason">"%1$s 제거됨 %2$s : %3$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_unban">"%1$s님이 %2$s님의 차단을 해제함"</string>
<string name="state_event_room_unban_by_you">"%1$s님의 차단을 해제함"</string>
<string name="state_event_room_unknown_membership_change">"%1$s님이 멤버십에 알려지지 않은 변경 사항을 만들었습니다."</string>
</resources>

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