mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into fix/6371-save-file-name-conflicts
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.accountselect.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.accountselect.api)
|
||||
implementation(projects.tests.testutils)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.test
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeAccountSelectEntryPoint(
|
||||
private val createNodeResult: (BuildContext, AccountSelectEntryPoint.Callback) -> Node = { _, _ -> lambdaError() },
|
||||
) : AccountSelectEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
callback: AccountSelectEntryPoint.Callback,
|
||||
): Node = createNodeResult(buildContext, callback)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.app.PictureInPictureParams
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
/**
|
||||
* Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder {
|
||||
val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
// If landscape orientation, invert the aspect ratio
|
||||
Rational(5, 3)
|
||||
} else {
|
||||
// In any other case, assume portrait orientation
|
||||
Rational(3, 5)
|
||||
}
|
||||
return setAspectRatio(aspectRatio)
|
||||
}
|
||||
+35
@@ -16,6 +16,7 @@ import androidx.core.text.toSpannable
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import timber.log.Timber
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Helper class to linkify text while preserving existing URL spans.
|
||||
@@ -23,6 +24,8 @@ import timber.log.Timber
|
||||
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
|
||||
*/
|
||||
object LinkifyHelper {
|
||||
private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(?<![a-zA-Z0-9+.-])[a-zA-Z][a-zA-Z0-9+.-]*://\[\S*""")
|
||||
|
||||
fun linkify(
|
||||
text: CharSequence,
|
||||
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
|
||||
@@ -47,6 +50,11 @@ object LinkifyHelper {
|
||||
val start = spannable.getSpanStart(urlSpan)
|
||||
val end = spannable.getSpanEnd(urlSpan)
|
||||
|
||||
if (urlSpan !in oldURLSpans && spannable.isEmailInsideFediverseHandle(urlSpan, start)) {
|
||||
spannable.removeSpan(urlSpan)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to avoid including trailing punctuation in the link.
|
||||
// Since this might fail in some edge cases, we catch the exception and just use the original end index.
|
||||
val newEnd = runCatchingExceptions {
|
||||
@@ -67,6 +75,24 @@ object LinkifyHelper {
|
||||
}
|
||||
}
|
||||
|
||||
for (match in BRACKETED_AUTHORITY_URL_REGEX.findAll(spannable)) {
|
||||
val start = match.range.first
|
||||
val end = match.range.last + 1
|
||||
val newEnd = runCatchingExceptions {
|
||||
adjustLinkifiedUrlSpanEndIndex(spannable, start, end)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to adjust end index for link span")
|
||||
}.getOrNull() ?: end
|
||||
|
||||
val url = spannable.substring(start, newEnd)
|
||||
if (!url.hasBracketedHost()) continue
|
||||
|
||||
for (span in spannable.getSpans<URLSpan>(start, newEnd)) {
|
||||
spannable.removeSpan(span)
|
||||
}
|
||||
spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
|
||||
// Restore old spans, remove new ones if there is a conflict
|
||||
for ((urlSpan, location) in oldURLSpans) {
|
||||
val (start, end) = location
|
||||
@@ -82,6 +108,15 @@ object LinkifyHelper {
|
||||
return spannable
|
||||
}
|
||||
|
||||
private fun String.hasBracketedHost(): Boolean {
|
||||
val host = runCatchingExceptions { URI(this) }.getOrNull()?.host ?: return false
|
||||
return host.startsWith('[') && host.endsWith(']')
|
||||
}
|
||||
|
||||
private fun Spannable.isEmailInsideFediverseHandle(urlSpan: URLSpan, start: Int): Boolean {
|
||||
return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@'
|
||||
}
|
||||
|
||||
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
|
||||
var end = end
|
||||
|
||||
|
||||
+2
@@ -13,3 +13,5 @@ import java.nio.charset.Charset
|
||||
|
||||
fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name())
|
||||
fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name())
|
||||
|
||||
fun String.takeIfNotBlank(): String? = ifBlank { null }
|
||||
|
||||
@@ -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>
|
||||
+93
@@ -36,6 +36,60 @@ class LinkifierHelperTest : RobolectricTest() {
|
||||
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds ipv6 URL`() {
|
||||
val text = "A url http://[2001:db8::1]:8008/path"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path")
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [30])
|
||||
fun `linkification of an ipv6 URL does not leave a phone number span`() {
|
||||
shadowOf(newInstanceOf(TelephonyManager::class.java)).setSimCountryIso("DE")
|
||||
val text = "A url http://[2001:db8::1]:8008/path"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification of an ipv6 URL trims trailing punctuation`() {
|
||||
val text = "A url http://[::1]/x."
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds an ipv6 URL of any scheme`() {
|
||||
val text = "A url ssh://[2001:db8::1]/path"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("ssh://[2001:db8::1]/path")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification ignores a bracketed authority which is not an address`() {
|
||||
val text = "A url http://[not-an-address]/path"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification ignores bracketed text which is not a URL`() {
|
||||
val text = "An array values[0]:1 and a bare [::1]"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds partial URL`() {
|
||||
val text = "A partial url matrix.org/test"
|
||||
@@ -87,6 +141,45 @@ class LinkifierHelperTest : RobolectricTest() {
|
||||
assertThat(urlSpans.first().url).isEqualTo("tel:1234")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification ignores fediverse handle`() {
|
||||
val text = "Follow me at @name@server.tld"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification ignores fediverse handle at the start of the text`() {
|
||||
val text = "@name@server.tld posted this"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds email next to a fediverse handle`() {
|
||||
val text = "@name@server.tld and john@doe.com"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification keeps an existing mailto span preceded by an at sign`() {
|
||||
val text = buildSpannedString {
|
||||
append("Mail me @")
|
||||
inSpans(URLSpan("mailto:john@doe.com")) {
|
||||
append("john@doe.com")
|
||||
}
|
||||
}
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification handles trailing dot`() {
|
||||
val text = "A url https://matrix.org."
|
||||
|
||||
+3
@@ -15,6 +15,9 @@ enum class AudioFocusRequester {
|
||||
MediaViewer,
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims and releases system audio focus, so that the app's own players do not talk over each other or over other apps.
|
||||
*/
|
||||
interface AudioFocus {
|
||||
/**
|
||||
* Request audio focus for the given requester.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
@@ -22,4 +23,6 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(projects.libraries.di)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
|
||||
+14
-5
@@ -48,9 +48,8 @@ class DefaultAudioFocus(
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
// For recording, ignore transient focus losses (e.g., notification sounds).
|
||||
// The AudioRecord API keeps capturing regardless.
|
||||
if (requester != AudioFocusRequester.RecordVoiceMessage) {
|
||||
Timber.d("AudioFocus: transient loss ($it)")
|
||||
if (requester.pausesOnTransientFocusLoss()) {
|
||||
onFocusLost()
|
||||
}
|
||||
}
|
||||
@@ -96,8 +95,8 @@ class DefaultAudioFocus(
|
||||
private fun AudioFocusRequester.toAudioUsage(): Int {
|
||||
return when (this) {
|
||||
AudioFocusRequester.ElementCall,
|
||||
AudioFocusRequester.VoiceMessage,
|
||||
AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION
|
||||
AudioFocusRequester.VoiceMessage,
|
||||
AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA
|
||||
}
|
||||
}
|
||||
@@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int {
|
||||
private fun AudioFocusRequester.toAudioStream(): Int {
|
||||
return when (this) {
|
||||
AudioFocusRequester.ElementCall,
|
||||
AudioFocusRequester.VoiceMessage,
|
||||
AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL
|
||||
AudioFocusRequester.VoiceMessage,
|
||||
AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean {
|
||||
return when (this) {
|
||||
// The AudioRecord API keeps capturing regardless.
|
||||
AudioFocusRequester.RecordVoiceMessage,
|
||||
AudioFocusRequester.VoiceMessage -> false
|
||||
AudioFocusRequester.ElementCall,
|
||||
AudioFocusRequester.MediaViewer -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioFocusRequester.willPausedWhenDucked(): Boolean {
|
||||
return when (this) {
|
||||
// (note that for Element Call, there is no action when the focus is lost)
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
* Copyright 2026 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.audio.impl
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import androidx.core.content.getSystemService
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.audio.api.AudioFocusRequester
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
class DefaultAudioFocusTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `voice message playback requests focus for media, not for a call`() {
|
||||
assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `media viewer playback requests focus for media`() {
|
||||
assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voice message recording requests focus for voice communication`() {
|
||||
assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element call requests focus for voice communication`() {
|
||||
assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient focus loss does not pause a voice message`() {
|
||||
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
|
||||
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a permanent focus loss pauses a voice message`() {
|
||||
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient focus loss does not stop a voice message recording`() {
|
||||
assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient focus loss still pauses the media viewer`() {
|
||||
assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue()
|
||||
}
|
||||
|
||||
private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val audioManager = requireNotNull(context.getSystemService<AudioManager>())
|
||||
var focusLost = false
|
||||
DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true }
|
||||
val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
|
||||
request.listener.onAudioFocusChange(focusChange)
|
||||
return focusLost
|
||||
}
|
||||
|
||||
private fun usageOf(requester: AudioFocusRequester): Int {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val audioManager = requireNotNull(context.getSystemService<AudioManager>())
|
||||
DefaultAudioFocus(context).requestAudioFocus(requester) {}
|
||||
val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
|
||||
return requireNotNull(request.audioFocusRequest).audioAttributes.usage
|
||||
}
|
||||
}
|
||||
+24
@@ -7,9 +7,33 @@
|
||||
|
||||
package io.element.android.libraries.cachestore.api
|
||||
|
||||
/**
|
||||
* A generic on-disk key/value cache, where each entry records when it was written so callers can decide for themselves when it is stale.
|
||||
*/
|
||||
interface CacheStore {
|
||||
/**
|
||||
* Stores an entry, replacing any previous one under the same key.
|
||||
*
|
||||
* @param key the entry to write.
|
||||
* @param data the value along with the time it was obtained.
|
||||
*/
|
||||
suspend fun storeData(key: String, data: CacheData)
|
||||
|
||||
/**
|
||||
* Reads an entry without applying any expiry of its own; judging staleness is up to the caller.
|
||||
*
|
||||
* @param key the entry to read.
|
||||
* @return the value and its timestamp, or `null` when nothing is stored under that key.
|
||||
*/
|
||||
suspend fun getData(key: String): CacheData?
|
||||
|
||||
/**
|
||||
* Removes one entry.
|
||||
*
|
||||
* @param key the entry to delete.
|
||||
*/
|
||||
suspend fun deleteData(key: String)
|
||||
|
||||
/** Empties the whole cache. */
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
package io.element.android.compound.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@@ -27,12 +30,28 @@ private fun Theme.coerceBlackTheme(allowBlackTheme: Boolean): Theme {
|
||||
@Composable
|
||||
fun Theme.isDark(): Boolean {
|
||||
return when (this) {
|
||||
Theme.System -> isSystemInDarkTheme()
|
||||
Theme.System -> isSystemThemeDark()
|
||||
Theme.Dark, Theme.Black -> true
|
||||
Theme.Light -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the system-wide theme is dark.
|
||||
*
|
||||
* This deliberately does not use [androidx.compose.foundation.isSystemInDarkTheme], which reads the activity configuration: `AppCompatDelegate` rewrites
|
||||
* that configuration whenever a non-system theme is selected, so it keeps reporting the previously forced value. The application configuration is left
|
||||
* untouched by AppCompat and is what AppCompat itself reads to expand `MODE_NIGHT_FOLLOW_SYSTEM`.
|
||||
*/
|
||||
@Composable
|
||||
private fun isSystemThemeDark(): Boolean {
|
||||
val applicationContext = LocalContext.current.applicationContext
|
||||
val activityConfiguration = LocalConfiguration.current
|
||||
return remember(activityConfiguration) {
|
||||
applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
}
|
||||
|
||||
fun Flow<String?>.mapToTheme(allowBlackTheme: Boolean): Flow<Theme> = map {
|
||||
when (it) {
|
||||
null -> Theme.System
|
||||
|
||||
@@ -11,19 +11,22 @@ package io.element.android.compound.theme
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
class ThemeTest {
|
||||
class ThemeTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `isDark for System dark returns true`() {
|
||||
`isDark for System`(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
systemUiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
expected = true,
|
||||
)
|
||||
}
|
||||
@@ -31,21 +34,38 @@ class ThemeTest {
|
||||
@Test
|
||||
fun `isDark for System light return false`() {
|
||||
`isDark for System`(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_NO,
|
||||
systemUiMode = Configuration.UI_MODE_NIGHT_NO,
|
||||
expected = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun `isDark for System`(
|
||||
uiMode: Int,
|
||||
@Test
|
||||
fun `isDark for System ignores the night mode AppCompat forced on the activity`() {
|
||||
`isDark for System`(
|
||||
systemUiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
activityUiMode = Configuration.UI_MODE_NIGHT_NO,
|
||||
expected = true,
|
||||
)
|
||||
`isDark for System`(
|
||||
systemUiMode = Configuration.UI_MODE_NIGHT_NO,
|
||||
activityUiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
expected = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun `isDark for System`(
|
||||
systemUiMode: Int,
|
||||
activityUiMode: Int = systemUiMode,
|
||||
expected: Boolean,
|
||||
) = runTest {
|
||||
val application = RuntimeEnvironment.getApplication()
|
||||
application.resources.configuration.uiMode = systemUiMode
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
var result: Boolean? = null
|
||||
CompositionLocalProvider(
|
||||
// Let set the system to dark
|
||||
LocalContext provides application,
|
||||
LocalConfiguration provides Configuration().apply {
|
||||
this.uiMode = uiMode
|
||||
this.uiMode = activityUiMode
|
||||
},
|
||||
) {
|
||||
result = Theme.System.isDark()
|
||||
|
||||
+12
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.libraries.core.extensions
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
|
||||
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
|
||||
@@ -114,3 +115,14 @@ fun String.toSafeLength(
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove accents and replace special characters by ASCII from the string.
|
||||
* For instance, "é" will be replaced by "e", "ç" by "c", etc.
|
||||
*/
|
||||
fun String.normalized(): String {
|
||||
// Normalize the string.
|
||||
return Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
// then keep only ASCII characters and replace non-ASCII characters with an empty string.
|
||||
.replace("[^\\p{ASCII}]".toRegex(), "")
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ fun String.isValidUrl(): Boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty
|
||||
* Ensure string starts with "http" (case-insensitively, so "HTTP://" / "HTTPS://" are respected). If it is
|
||||
* not the case, "https://" is added, only if the String is not empty.
|
||||
*/
|
||||
fun String.ensureProtocol(): String {
|
||||
return when {
|
||||
isEmpty() -> this
|
||||
!startsWith("http") -> "https://$this"
|
||||
!startsWith("http", ignoreCase = true) -> "https://$this"
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -74,4 +74,10 @@ class BasicExtensionsTest {
|
||||
val result = textWithDirectionOverrides.filterDirectionOverrides()
|
||||
assertEquals("123456789", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalized removes accents and replace special characters from text`() {
|
||||
assertEquals("Stefan", "Ștefan".normalized())
|
||||
assertEquals("ca enleve etre", "ça enlève être".normalized())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.core.uri
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class UrlUtilsTest {
|
||||
@Test
|
||||
fun `ensureProtocol keeps an empty string unchanged`() {
|
||||
assertThat("".ensureProtocol()).isEqualTo("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ensureProtocol prepends https when there is no scheme`() {
|
||||
assertThat("matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ensureProtocol keeps an existing http or https scheme`() {
|
||||
assertThat("https://matrix.org".ensureProtocol()).isEqualTo("https://matrix.org")
|
||||
assertThat("http://matrix.org".ensureProtocol()).isEqualTo("http://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ensureProtocol keeps an uppercase scheme rather than double-prepending`() {
|
||||
assertThat("HTTPS://matrix.org".ensureProtocol()).isEqualTo("HTTPS://matrix.org")
|
||||
assertThat("Http://matrix.org".ensureProtocol()).isEqualTo("Http://matrix.org")
|
||||
}
|
||||
}
|
||||
+27
@@ -15,8 +15,35 @@ import javax.crypto.SecretKey
|
||||
* Simple service to provide encryption and decryption operations.
|
||||
*/
|
||||
interface EncryptionDecryptionService {
|
||||
/**
|
||||
* Creates a cipher ready to encrypt, for the callers that need the cipher itself, such as to bind it to a biometric prompt.
|
||||
*
|
||||
* @param key the key to encrypt with.
|
||||
*/
|
||||
fun createEncryptionCipher(key: SecretKey): Cipher
|
||||
|
||||
/**
|
||||
* Creates a cipher ready to decrypt, for the callers that need the cipher itself.
|
||||
*
|
||||
* @param key the key to decrypt with.
|
||||
* @param initializationVector the vector produced when the data was encrypted.
|
||||
*/
|
||||
fun createDecryptionCipher(key: SecretKey, initializationVector: ByteArray): Cipher
|
||||
|
||||
/**
|
||||
* Encrypts data in one call.
|
||||
*
|
||||
* @param key the key to encrypt with.
|
||||
* @param input the plaintext bytes.
|
||||
* @return the ciphertext along with the initialization vector needed to decrypt it.
|
||||
*/
|
||||
fun encrypt(key: SecretKey, input: ByteArray): EncryptionResult
|
||||
|
||||
/**
|
||||
* Decrypts data in one call.
|
||||
*
|
||||
* @param key the key the data was encrypted with.
|
||||
* @param encryptionResult the ciphertext and its initialization vector, as returned by [encrypt].
|
||||
*/
|
||||
fun decrypt(key: SecretKey, encryptionResult: EncryptionResult): ByteArray
|
||||
}
|
||||
|
||||
+7
@@ -16,10 +16,17 @@ import javax.crypto.SecretKey
|
||||
* Implementation should be able to store the generated key securely.
|
||||
*/
|
||||
interface SecretKeyRepository {
|
||||
/**
|
||||
* Whether a key exists for the given alias, re-emitted when it is created or deleted.
|
||||
*
|
||||
* @param alias the alias to watch.
|
||||
*/
|
||||
fun hasKey(alias: String): Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Get or create a secret key for a given alias.
|
||||
* Note that [requiresUserAuthentication] only applies when the key is created: an existing key keeps the protection it was created with.
|
||||
*
|
||||
* @param alias the alias to use
|
||||
* @param requiresUserAuthentication true if the key should be protected by user authentication
|
||||
*/
|
||||
|
||||
+10
@@ -8,7 +8,17 @@
|
||||
|
||||
package io.element.android.libraries.dateformatter.api
|
||||
|
||||
/**
|
||||
* Formats timestamps for display, in the user's locale and time zone.
|
||||
*/
|
||||
interface DateFormatter {
|
||||
/**
|
||||
* Formats a timestamp according to [mode].
|
||||
*
|
||||
* @param timestamp milliseconds since the epoch; a `null` value yields an empty string, so callers need no null check of their own.
|
||||
* @param mode how much of the date and time to include; see [DateFormatterMode] for examples of each.
|
||||
* @param useRelative true to prefer wording relative to today, such as "Today" or "This month", where the mode allows it.
|
||||
*/
|
||||
fun format(
|
||||
timestamp: Long?,
|
||||
mode: DateFormatterMode = DateFormatterMode.Full,
|
||||
|
||||
+5
@@ -21,6 +21,11 @@ import kotlin.time.Duration
|
||||
* - 30 seconds → "30 seconds"
|
||||
*/
|
||||
interface DurationFormatter {
|
||||
/**
|
||||
* Formats the duration using its largest appropriate unit, rounding rather than listing several units.
|
||||
*
|
||||
* @param duration the duration to describe.
|
||||
*/
|
||||
fun format(duration: Duration): String
|
||||
}
|
||||
|
||||
|
||||
@@ -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 في %2$s"</string>
|
||||
<string name="common_date_this_month">"هذا الشهر"</string>
|
||||
</resources>
|
||||
+11
@@ -13,6 +13,17 @@ 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
|
||||
|
||||
/**
|
||||
* Builds the app's internal deep links, used to point a notification or a shortcut at a precise place in the app.
|
||||
*/
|
||||
fun interface DeepLinkCreator {
|
||||
/**
|
||||
* Builds a link to the most precise target the arguments allow, ignoring the deeper ones once an outer one is `null`.
|
||||
*
|
||||
* @param sessionId the session to open.
|
||||
* @param roomId the room to open, or `null` to stop at the room list.
|
||||
* @param threadId the thread to open within that room, or `null` for the main timeline.
|
||||
* @param eventId the event to focus on, or `null` to open at the latest message.
|
||||
*/
|
||||
fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String
|
||||
}
|
||||
|
||||
+7
@@ -10,6 +10,13 @@ package io.element.android.libraries.deeplink.api
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Extracts the app's own deep link data from an incoming intent; see [DeepLinkCreator] for the other direction.
|
||||
*/
|
||||
fun interface DeeplinkParser {
|
||||
/**
|
||||
* @param intent the intent the app was started or resumed with.
|
||||
* @return the session, room and event to open, or `null` when the intent is not one of the app's deep links.
|
||||
*/
|
||||
fun getFromIntent(intent: Intent): DeeplinkData?
|
||||
}
|
||||
|
||||
+6
@@ -10,6 +10,12 @@ package io.element.android.libraries.deeplink.api.usecase
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* Opens the system share sheet with an invitation to install the app.
|
||||
*/
|
||||
fun interface InviteFriendsUseCase {
|
||||
/**
|
||||
* @param activity the activity the share sheet is shown from.
|
||||
*/
|
||||
fun execute(activity: Activity)
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.async
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private val DefaultShowDelay = 300.milliseconds
|
||||
private val DefaultMinDisplayDuration = 500.milliseconds
|
||||
private val DefaultFailureDuration = 500.milliseconds
|
||||
|
||||
/**
|
||||
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
|
||||
*
|
||||
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
|
||||
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
|
||||
* prevents the indicator from flashing for operations that complete almost instantly.
|
||||
*
|
||||
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
|
||||
* never debounced) and auto-dismisses after [failureDuration].
|
||||
*
|
||||
* @param asyncAction the action whose progress drives the indicator.
|
||||
* @param loading the composable to display while loading.
|
||||
* @param failure the composable to display on failure.
|
||||
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
|
||||
* @param state the [AsyncIndicatorState] backing the host.
|
||||
* @param showDelay how long the loading state must persist before the indicator is shown.
|
||||
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
|
||||
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
|
||||
*/
|
||||
@Composable
|
||||
fun AsyncActionIndicator(
|
||||
asyncAction: AsyncAction<*>,
|
||||
loading: @Composable () -> Unit,
|
||||
failure: @Composable (Throwable) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
|
||||
showDelay: Duration = DefaultShowDelay,
|
||||
minDisplayDuration: Duration = DefaultMinDisplayDuration,
|
||||
failureDuration: Duration = DefaultFailureDuration,
|
||||
) {
|
||||
AsyncIndicatorHost(modifier = modifier, state = state)
|
||||
AsyncActionIndicatorEffect(
|
||||
asyncAction = asyncAction,
|
||||
state = state,
|
||||
showDelay = showDelay,
|
||||
minDisplayDuration = minDisplayDuration,
|
||||
failureDuration = failureDuration,
|
||||
loading = loading,
|
||||
failure = failure,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AsyncActionIndicatorEffect(
|
||||
asyncAction: AsyncAction<*>,
|
||||
state: AsyncIndicatorState,
|
||||
showDelay: Duration,
|
||||
minDisplayDuration: Duration,
|
||||
failureDuration: Duration,
|
||||
loading: @Composable () -> Unit,
|
||||
failure: @Composable (Throwable) -> Unit,
|
||||
timeSource: TimeSource = TimeSource.Monotonic,
|
||||
) {
|
||||
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
|
||||
val currentLoading by rememberUpdatedState(loading)
|
||||
val currentFailure by rememberUpdatedState(failure)
|
||||
// Mark set when the loader actually becomes visible, null while it is hidden.
|
||||
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
|
||||
LaunchedEffect(asyncAction) {
|
||||
when (asyncAction) {
|
||||
is AsyncAction.Loading -> {
|
||||
// If the operation completes before this delay elapses, the effect is cancelled
|
||||
// and the loader is never shown.
|
||||
delay(showDelay)
|
||||
state.enqueue(composable = currentLoading)
|
||||
loaderShownAt = timeSource.markNow()
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
// Always surface failures immediately, even if the loader was never shown.
|
||||
val error = asyncAction.error
|
||||
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
|
||||
loaderShownAt = null
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
// Hold the loader for its minimum duration before clearing it.
|
||||
loaderShownAt?.let { shownAt ->
|
||||
val remaining = minDisplayDuration - shownAt.elapsedNow()
|
||||
if (remaining > Duration.ZERO) delay(remaining)
|
||||
}
|
||||
state.clear()
|
||||
loaderShownAt = null
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -8,9 +8,11 @@
|
||||
|
||||
package io.element.android.libraries.designsystem.components.avatar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import java.text.BreakIterator
|
||||
|
||||
@Immutable
|
||||
data class AvatarData(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
|
||||
+3
@@ -8,9 +8,11 @@
|
||||
|
||||
package io.element.android.libraries.designsystem.components.avatar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Immutable
|
||||
enum class AvatarSize(val dp: Dp) {
|
||||
CurrentUserTopBar(32.dp),
|
||||
CurrentRoomTopBar(32.dp),
|
||||
@@ -18,6 +20,7 @@ enum class AvatarSize(val dp: Dp) {
|
||||
IncomingCall(140.dp),
|
||||
RoomDetailsHeader(96.dp),
|
||||
RoomListItem(52.dp),
|
||||
SearchRoomListItem(32.dp),
|
||||
ThreadsListItem(52.dp),
|
||||
|
||||
SpaceListItem(52.dp),
|
||||
|
||||
+5
-1
@@ -200,11 +200,15 @@ internal fun WaveformPlaybackViewPreview() = ElementPreview {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): ImmutableList<Float> {
|
||||
internal fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): ImmutableList<Float> {
|
||||
if (maxSamplesCount <= 0) {
|
||||
return persistentListOf()
|
||||
}
|
||||
|
||||
if (isEmpty()) {
|
||||
return List(maxSamplesCount) { 0f }.toImmutableList()
|
||||
}
|
||||
|
||||
// Filter the data to keep only the expected number of samples
|
||||
val result = if (this.size > maxSamplesCount) {
|
||||
(0..<maxSamplesCount)
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.component.async
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TestTimeSource
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AsyncActionIndicatorTest {
|
||||
private val showDelay = 300.milliseconds
|
||||
private val minDisplay = 500.milliseconds
|
||||
|
||||
@Test
|
||||
fun `loader is not shown before the show delay elapses`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(100.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
// Once the show delay has fully elapsed, the loader is shown.
|
||||
advanceTimeBy(300.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loader is never shown when the operation completes before the show delay`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(100.milliseconds)
|
||||
runCurrent()
|
||||
action.value = AsyncAction.Success(Unit)
|
||||
advanceTimeBy(1000.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Failure(Exception("failed"))
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loader stays visible for the minimum duration once shown`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
val timeSource = TestTimeSource()
|
||||
indicatorFlow(action, state, timeSource).test {
|
||||
// Show the loader.
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(400.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
// 200ms of display has already elapsed when the operation succeeds.
|
||||
timeSource += 200.milliseconds
|
||||
action.value = AsyncAction.Success(Unit)
|
||||
runCurrent()
|
||||
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
|
||||
advanceTimeBy(200.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
// Past the minimum display duration -> cleared.
|
||||
advanceTimeBy(200.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun indicatorFlow(
|
||||
action: State<AsyncAction<*>>,
|
||||
state: AsyncIndicatorState,
|
||||
timeSource: TimeSource = TimeSource.Monotonic,
|
||||
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
|
||||
AsyncActionIndicatorEffect(
|
||||
asyncAction = action.value,
|
||||
state = state,
|
||||
showDelay = showDelay,
|
||||
minDisplayDuration = minDisplay,
|
||||
failureDuration = minDisplay,
|
||||
loading = {},
|
||||
failure = {},
|
||||
timeSource = timeSource,
|
||||
)
|
||||
state.currentAnimationState.targetState
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations 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.designsystem.components.media
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
|
||||
class WaveformNormalisationTest {
|
||||
@Test
|
||||
fun `an empty waveform is padded to the requested number of samples`() {
|
||||
val result = persistentListOf<Float>().normalisedData(30)
|
||||
assertThat(result).hasSize(30)
|
||||
assertThat(result.toSet()).containsExactly(0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty waveform stays empty when there is no room to display it`() {
|
||||
assertThat(persistentListOf<Float>().normalisedData(0)).isEmpty()
|
||||
assertThat(persistentListOf<Float>().normalisedData(-1)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a waveform shorter than the display count is kept as it is`() {
|
||||
val waveform = persistentListOf(0.1f, 0.2f, 0.3f)
|
||||
assertThat(waveform.normalisedData(30)).containsExactly(0.1f, 0.2f, 0.3f).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a waveform longer than the display count is downsampled`() {
|
||||
val waveform = persistentListOf(0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f)
|
||||
assertThat(waveform.normalisedData(3)).hasSize(3)
|
||||
}
|
||||
}
|
||||
+7
@@ -11,7 +11,14 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
|
||||
fun interface EmojiPickerPresenter : Presenter<EmojiPickerState> {
|
||||
/**
|
||||
* Creates a presenter for one picker instance.
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* @param getRecentEmojis how the picker sources its recent tab; pass [io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis]
|
||||
* for a picker that should have no recent tab at all.
|
||||
*/
|
||||
fun create(getRecentEmojis: GetRecentEmojis): EmojiPickerPresenter
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -13,9 +13,16 @@ import androidx.compose.ui.Modifier
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
/**
|
||||
* Renders the emoji picker, so that callers can embed it without depending on its implementation.
|
||||
*
|
||||
* See [NoOpEmojiPickerRenderer] for a renderer that draws nothing, used where the picker is disabled.
|
||||
*/
|
||||
@Immutable
|
||||
interface EmojiPickerRenderer {
|
||||
/**
|
||||
* Draws the picker.
|
||||
*
|
||||
* @param state opaque state produced by [EmojiPickerPresenter.present].
|
||||
* @param onSelectEmoji invoked when the user taps an emoji (including a skin-tone variant).
|
||||
* @param selectedEmojis emojis to visually mark as already selected.
|
||||
|
||||
+6
@@ -8,6 +8,12 @@
|
||||
|
||||
package io.element.android.libraries.emoji.api.recentemojis
|
||||
|
||||
/**
|
||||
* Records that an emoji was just used, so it surfaces in the picker's recent list; see [GetRecentEmojis] for the read side.
|
||||
*/
|
||||
fun interface AddRecentEmoji {
|
||||
/**
|
||||
* @param emoji the emoji that was just used, as its unicode representation.
|
||||
*/
|
||||
suspend operator fun invoke(emoji: String): Result<Unit>
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
* Returns the list of recently used emojis for reactions.
|
||||
*/
|
||||
fun interface GetRecentEmojis {
|
||||
/** Returns the recently used emojis, most recent first; see [EmptyGetRecentEmojis] for a picker with no recent tab. */
|
||||
suspend operator fun invoke(): Result<ImmutableList<String>>
|
||||
}
|
||||
|
||||
|
||||
@@ -36,5 +36,5 @@ dependencies {
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testCommonDependencies(libs)
|
||||
testCommonDependencies(libs, includeTestComposeView = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
|
||||
queryState = state.searchQuery,
|
||||
resultState = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="emoji_picker_category_activity">"أنشطة"</string>
|
||||
<string name="emoji_picker_category_flags">"أعلام"</string>
|
||||
<string name="emoji_picker_category_foods">"أكل وشرب"</string>
|
||||
<string name="emoji_picker_category_nature">"حيوانات وطبيعة"</string>
|
||||
<string name="emoji_picker_category_objects">"أشياء"</string>
|
||||
<string name="emoji_picker_category_people">"الابتسامات والأشخاص"</string>
|
||||
<string name="emoji_picker_category_places">"سفر وأماكن"</string>
|
||||
<string name="emoji_picker_category_recent">"الرموز التعبيرية الأخيرة"</string>
|
||||
<string name="emoji_picker_category_symbols">"رموز"</string>
|
||||
<string name="emoji_picker_search_placeholder">"البحث عن رموز تعبيرية"</string>
|
||||
</resources>
|
||||
@@ -7,5 +7,7 @@
|
||||
<string name="emoji_picker_category_objects">"Objetos"</string>
|
||||
<string name="emoji_picker_category_people">"Emojis y personas"</string>
|
||||
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis recientes"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Buscar emojis"</string>
|
||||
</resources>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<string name="emoji_picker_category_flags">"Flagi"</string>
|
||||
<string name="emoji_picker_category_foods">"Jedzenie i napoje"</string>
|
||||
<string name="emoji_picker_category_nature">"Zwierzęta i natura"</string>
|
||||
<string name="emoji_picker_category_objects">"Obiekty"</string>
|
||||
<string name="emoji_picker_category_objects">"Przedmioty"</string>
|
||||
<string name="emoji_picker_category_people">"Buźki i osoby"</string>
|
||||
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
|
||||
<string name="emoji_picker_category_recent">"Ostatnie emoji"</string>
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsEqualTo
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.hasScrollToIndexAction
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
private val PICKER_HEIGHT = 600.dp
|
||||
|
||||
class EmojiPickerViewTest : RobolectricTest() {
|
||||
@Config(qualifiers = "w400dp-h800dp")
|
||||
@Test
|
||||
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setEmojiPickerView()
|
||||
onNode(hasScrollToIndexAction())
|
||||
.getBoundsInRoot()
|
||||
.bottom
|
||||
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
|
||||
setContent {
|
||||
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
|
||||
EmojiPickerView(
|
||||
state = aDefaultEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(
|
||||
persistentListOf(
|
||||
Emoji(
|
||||
hexcode = "0x00",
|
||||
label = "grinning face",
|
||||
tags = persistentListOf("grinning"),
|
||||
shortcodes = persistentListOf("smile"),
|
||||
unicode = "😀",
|
||||
skins = null,
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
onSelectEmoji = {},
|
||||
selectedEmojis = persistentSetOf(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -10,6 +10,12 @@ package io.element.android.libraries.eventformatter.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
|
||||
/**
|
||||
* Formats the short summary of a pinned event shown in the banner at the top of a room.
|
||||
*/
|
||||
interface PinnedMessagesBannerFormatter {
|
||||
/**
|
||||
* @param event the pinned event to describe.
|
||||
*/
|
||||
fun format(event: EventTimelineItem): CharSequence
|
||||
}
|
||||
|
||||
+18
@@ -10,7 +10,25 @@ package io.element.android.libraries.eventformatter.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||
|
||||
/**
|
||||
* Formats the one-line preview of a room's latest event, as shown in the room list.
|
||||
*/
|
||||
interface RoomLatestEventFormatter {
|
||||
/**
|
||||
* Formats an event that is still local, i.e. queued or being sent.
|
||||
*
|
||||
* @param latestEvent the local event to describe.
|
||||
* @param isDmRoom whether the room is a direct message, which lets the sender name be left out.
|
||||
* @return the preview text, or `null` when this kind of event should not be previewed.
|
||||
*/
|
||||
fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence?
|
||||
|
||||
/**
|
||||
* Formats an event that has been received from the server.
|
||||
*
|
||||
* @param latestEvent the remote event to describe.
|
||||
* @param isDmRoom whether the room is a direct message, which lets the sender name be left out.
|
||||
* @return the preview text, or `null` when this kind of event should not be previewed.
|
||||
*/
|
||||
fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence?
|
||||
}
|
||||
|
||||
+17
@@ -13,7 +13,16 @@ 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
|
||||
|
||||
/**
|
||||
* Turns a state or membership event into the human readable sentence shown in the timeline, such as "Alice joined the room".
|
||||
*/
|
||||
interface TimelineEventFormatter {
|
||||
/**
|
||||
* Convenience overload that reads the sender and ownership from the timeline item itself.
|
||||
*
|
||||
* @param event the timeline item to describe.
|
||||
* @return the sentence to display, or `null` when this kind of event is not rendered as text.
|
||||
*/
|
||||
fun format(event: EventTimelineItem): CharSequence? {
|
||||
return format(
|
||||
content = event.content,
|
||||
@@ -22,5 +31,13 @@ interface TimelineEventFormatter {
|
||||
senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param content the event content to describe.
|
||||
* @param isOutgoing whether the event was sent by the current user, which selects the first-person wording.
|
||||
* @param sender the user who sent the event.
|
||||
* @param senderDisambiguatedDisplayName the sender's display name, already suffixed with their id when it is ambiguous in the room.
|
||||
* @return the sentence to display, or `null` when this kind of event is not rendered as text.
|
||||
*/
|
||||
fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence?
|
||||
}
|
||||
|
||||
+2
-2
@@ -135,7 +135,7 @@ class DefaultRoomLatestEventFormatter(
|
||||
val message = when (val messageType: MessageType = type) {
|
||||
// Doesn't need a prefix
|
||||
is EmoteMessageType -> {
|
||||
return "* $senderDisambiguatedDisplayName ${messageType.body}"
|
||||
return "* ${senderDisambiguatedDisplayName.bidiIsolate()} ${messageType.body}"
|
||||
}
|
||||
is TextMessageType -> {
|
||||
messageType.toPlainText(permalinkParser)
|
||||
@@ -189,7 +189,7 @@ class DefaultRoomLatestEventFormatter(
|
||||
if (isOutgoing) {
|
||||
sp.getString(CommonStrings.common_you)
|
||||
} else {
|
||||
senderDisambiguatedDisplayName
|
||||
senderDisambiguatedDisplayName.bidiIsolate()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -14,6 +14,12 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
|
||||
private const val FIRST_STRONG_ISOLATE = 0x2068
|
||||
private const val POP_DIRECTIONAL_ISOLATE = 0x2069
|
||||
|
||||
/** Wraps the string between Unicode isolate characters so its own direction cannot reorder the text around it. */
|
||||
internal fun String.bidiIsolate(): String = Char(FIRST_STRONG_ISOLATE) + this + Char(POP_DIRECTIONAL_ISOLATE)
|
||||
|
||||
internal fun CharSequence.prefixWith(prefix: String): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
|
||||
@@ -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>
|
||||
@@ -19,6 +19,8 @@
|
||||
<string name="state_event_room_avatar_removed_by_you">"چهرک اتاق را برداشتید"</string>
|
||||
<string name="state_event_room_ban">"%2$s به دست %1$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>
|
||||
@@ -55,6 +57,8 @@
|
||||
<string name="state_event_room_reject_by_you">"دعوت را رد کردید"</string>
|
||||
<string name="state_event_room_remove">"%2$s به دست %1$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>
|
||||
|
||||
+27
-10
@@ -91,7 +91,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
assertThat(result).isEqualTo(expected)
|
||||
} else {
|
||||
assertThat(result).isInstanceOf(AnnotatedString::class.java)
|
||||
assertThat(result.toString()).isEqualTo("$senderName: $expected")
|
||||
assertThat(result.toString()).isEqualTo("$senderName: $expected")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,13 +103,13 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
val info = ImageInfo(null, null, null, null, null, null, null)
|
||||
val message = createLatestEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
|
||||
val result = formatter.format(message, false)
|
||||
val expectedBody = someoneElseId.value + ": Sticker: a sticker body"
|
||||
val expectedBody = "" + someoneElseId.value + ": Sticker: a sticker body"
|
||||
// Check we have formatting
|
||||
assertThat(result is AnnotatedString).isTrue()
|
||||
// And there is a bold span for the 'Sticker' part
|
||||
val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
|
||||
assertThat(boldSpanStyle).isNotNull()
|
||||
val spanStart = someoneElseId.value.length + 2
|
||||
val spanStart = someoneElseId.value.length + 4
|
||||
assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 7)
|
||||
assertThat(result.toString()).isEqualTo(expectedBody)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
assertThat(result).isEqualTo(expected)
|
||||
} else {
|
||||
assertThat(result).isInstanceOf(AnnotatedString::class.java)
|
||||
assertThat(result.toString()).isEqualTo("$senderName: $expected")
|
||||
assertThat(result.toString()).isEqualTo("$senderName: $expected")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,12 +152,29 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected)
|
||||
} else {
|
||||
assertWithMessage("$type does not create an AnnotatedString").that(result).isInstanceOf(AnnotatedString::class.java)
|
||||
assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected")
|
||||
assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "en")
|
||||
fun `a right to left sender name is isolated so it cannot reorder the preview`() {
|
||||
val senderName = "مرحبا"
|
||||
val body = "Hello"
|
||||
val message = createLatestEvent(
|
||||
sentByYou = false,
|
||||
senderDisplayName = senderName,
|
||||
content = MessageContent(body, null, false, null, TextMessageType(body, null)),
|
||||
)
|
||||
val result = formatter.format(message, false)
|
||||
assertThat(result.toString()).isEqualTo("$senderName: $body")
|
||||
val boldSpanStyle = (result as AnnotatedString).spanStyles.first { it.item.fontWeight == FontWeight.Bold }
|
||||
assertThat(boldSpanStyle.start).isEqualTo(0)
|
||||
assertThat(boldSpanStyle.end).isEqualTo(senderName.length + 2)
|
||||
}
|
||||
|
||||
// region Message contents
|
||||
|
||||
@Test
|
||||
@@ -166,7 +183,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
testMessageContents(
|
||||
sentByYou = false,
|
||||
senderName = "Alice",
|
||||
expectedPrefix = "Alice",
|
||||
expectedPrefix = "Alice",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -233,7 +250,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
is StickerMessageType -> "Sticker: Shared body"
|
||||
is FileMessageType -> "File: Shared body"
|
||||
is LocationMessageType -> "Shared location"
|
||||
is EmoteMessageType -> "* $senderName ${type.body}"
|
||||
is EmoteMessageType -> "* $senderName ${type.body}"
|
||||
is TextMessageType,
|
||||
is NoticeMessageType,
|
||||
is OtherMessageType -> body
|
||||
@@ -275,7 +292,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
is TextMessageType,
|
||||
is NoticeMessageType,
|
||||
is OtherMessageType -> "$expectedPrefix: $body"
|
||||
is EmoteMessageType -> "* $senderName ${type.body}"
|
||||
is EmoteMessageType -> "* $senderName ${type.body}"
|
||||
}
|
||||
val shouldCreateAnnotatedString = when (type) {
|
||||
is VideoMessageType -> true
|
||||
@@ -944,7 +961,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
assertThat(formatter.format(mineContentEvent, false).toString()).isEqualTo("You: Poll: Do you like polls?")
|
||||
|
||||
val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent)
|
||||
assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("Bob: Poll: Do you like polls?")
|
||||
assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("Bob: Poll: Do you like polls?")
|
||||
|
||||
val result = formatter.format(contentEvent, false)
|
||||
// Check we have formatting
|
||||
@@ -952,7 +969,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
|
||||
// And there is a bold span for the 'Poll' part
|
||||
val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
|
||||
assertThat(boldSpanStyle).isNotNull()
|
||||
val spanStart = "Bob".length + 2
|
||||
val spanStart = "Bob".length + 4
|
||||
assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 4)
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ package io.element.android.libraries.featureflag.api
|
||||
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
|
||||
/**
|
||||
* Describes one feature flag: how it is identified, how it is presented in the developer options, and what it defaults to.
|
||||
*/
|
||||
interface Feature {
|
||||
/**
|
||||
* Unique key to identify the feature.
|
||||
|
||||
+3
@@ -11,6 +11,9 @@ package io.element.android.libraries.featureflag.api
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Reads and, where a mutable provider is registered, changes the state of the feature flags.
|
||||
*/
|
||||
interface FeatureFlagService {
|
||||
/**
|
||||
* @param feature the feature to check for
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations 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.featureflag.api
|
||||
|
||||
/**
|
||||
* Provides a list of available [Feature]s that the application supports.
|
||||
* Used by [FeatureFlagService.getAvailableFeatures] to obtain a list of features
|
||||
* that can be presented to the user.
|
||||
*
|
||||
* Multiple instances can exist within the application, and all instances are polled
|
||||
* to provide features for the list. This means that additional features can be provided
|
||||
* by compile-time extensions.
|
||||
*/
|
||||
fun interface FeaturesProvider {
|
||||
fun provide(): List<Feature>
|
||||
}
|
||||
+3
-2
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeaturesProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
class DefaultFeatureFlagService(
|
||||
private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val featuresProvider: FeaturesProvider,
|
||||
private val featuresProviders: Set<FeaturesProvider>,
|
||||
) : FeatureFlagService {
|
||||
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
|
||||
return if (feature.isFinished) {
|
||||
@@ -47,7 +48,7 @@ class DefaultFeatureFlagService(
|
||||
includeFinishedFeatures: Boolean,
|
||||
isInLabs: Boolean,
|
||||
): List<Feature> {
|
||||
return featuresProvider.provide().filter { flag ->
|
||||
return featuresProviders.flatMap { provider -> provider.provide() }.filter { flag ->
|
||||
(includeFinishedFeatures || !flag.isFinished) &&
|
||||
flag.isInLabs == isInLabs
|
||||
}
|
||||
|
||||
+3
-6
@@ -9,15 +9,12 @@
|
||||
package io.element.android.libraries.featureflag.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.api.FeaturesProvider
|
||||
|
||||
fun interface FeaturesProvider {
|
||||
fun provide(): List<Feature>
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
class DefaultFeaturesProvider : FeaturesProvider {
|
||||
override fun provide(): List<Feature> = FeatureFlags.entries
|
||||
}
|
||||
+2
-1
@@ -12,6 +12,7 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeaturesProvider
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -166,5 +167,5 @@ private fun createDefaultFeatureFlagService(
|
||||
) = DefaultFeatureFlagService(
|
||||
providers = providers,
|
||||
buildMeta = buildMeta,
|
||||
featuresProvider = { features }
|
||||
featuresProviders = setOf(FeaturesProvider { features })
|
||||
)
|
||||
|
||||
+2
@@ -15,9 +15,11 @@ import androidx.compose.runtime.State
|
||||
* A set of State<Boolean> to observe to display or not the indicators in the UI.
|
||||
*/
|
||||
interface IndicatorService {
|
||||
/** Whether to show the red dot on the room list top bar, which aggregates the reasons the user should open the settings. */
|
||||
@Composable
|
||||
fun showRoomListTopBarIndicator(): State<Boolean>
|
||||
|
||||
/** Whether to show the red dot on the chat backup setting, i.e. when recovery needs the user's attention. */
|
||||
@Composable
|
||||
fun showSettingChatBackupIndicator(): State<Boolean>
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* An interface to fetch content from a URL using a simple GET request launched from a Matrix client.
|
||||
*/
|
||||
interface ClientUrlContentFetcher {
|
||||
/**
|
||||
* The domain of the public server for this homeserver.
|
||||
*/
|
||||
val server: String?
|
||||
|
||||
/**
|
||||
* The URL of the actual Matrix homeserver.
|
||||
*/
|
||||
val homeserverUrl: String
|
||||
|
||||
/**
|
||||
* Execute generic GET requests through the SDKs internal HTTP client.
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<ByteArray>
|
||||
}
|
||||
+2
@@ -10,6 +10,8 @@ package io.element.android.libraries.matrix.api
|
||||
/**
|
||||
* Provides information about the capabilities of the homeserver.
|
||||
*
|
||||
* The getters read a snapshot held by the SDK rather than querying the server on each call, so [refresh] must be used to pick up server-side changes.
|
||||
*
|
||||
* Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation
|
||||
*/
|
||||
interface HomeserverCapabilitiesProvider {
|
||||
|
||||
+261
-20
@@ -54,59 +54,246 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.Optional
|
||||
|
||||
interface MatrixClient {
|
||||
/**
|
||||
* Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations.
|
||||
*
|
||||
* One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation.
|
||||
* Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread.
|
||||
*/
|
||||
interface MatrixClient : ClientUrlContentFetcher {
|
||||
/** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */
|
||||
val sessionId: SessionId
|
||||
|
||||
/** Identifier of this device, as assigned by the homeserver when the session was created. */
|
||||
val deviceId: DeviceId
|
||||
|
||||
/** Base URL of the homeserver this client is connected to. */
|
||||
val homeserverUrl: String
|
||||
/** Directories holding this session's databases, caches and downloaded media. */
|
||||
val sessionPaths: SessionPaths
|
||||
|
||||
/**
|
||||
* Display name and avatar of the logged in user.
|
||||
* Seeded from the local session store so a value is available before the first network fetch, then kept up to date by the SDK.
|
||||
*/
|
||||
val userProfile: StateFlow<MatrixUser>
|
||||
|
||||
/** Gives access to the list of rooms the user is a member of, or has been invited to. */
|
||||
val roomListService: RoomListService
|
||||
|
||||
/** Gives access to the spaces the user belongs to and their hierarchy. */
|
||||
val spaceService: SpaceService
|
||||
|
||||
/** Controls the sync loop of this session and exposes its state. */
|
||||
val syncService: SyncService
|
||||
|
||||
/** Handles verifying this session against the user's other devices or their recovery key. */
|
||||
val sessionVerificationService: SessionVerificationService
|
||||
|
||||
/** Manages the push gateways (pushers) registered on the homeserver for this session. */
|
||||
val pushersService: PushersService
|
||||
|
||||
/** Resolves incoming push notifications into renderable events. */
|
||||
val notificationService: NotificationService
|
||||
|
||||
/** Reads and writes the push notification rules of the account. */
|
||||
val notificationSettingsService: NotificationSettingsService
|
||||
|
||||
/** Gives access to end-to-end encryption state, recovery and key backup. */
|
||||
val encryptionService: EncryptionService
|
||||
|
||||
/** Gives access to the public room directory of the homeserver. */
|
||||
val roomDirectoryService: RoomDirectoryService
|
||||
|
||||
/** Whether this live client was built with a message search index attached. */
|
||||
val isMessageSearchAvailable: Boolean
|
||||
|
||||
/** Searches messages in the local index; only usable when [isMessageSearchAvailable] is `true`. */
|
||||
val messageSearchService: MessageSearchService
|
||||
|
||||
/** Reads and writes the account settings controlling whether media previews and avatars are shown. */
|
||||
val mediaPreviewService: MediaPreviewService
|
||||
|
||||
/** Downloads media content, thumbnails and files from the homeserver, caching them on disk. */
|
||||
val matrixMediaLoader: MatrixMediaLoader
|
||||
|
||||
/**
|
||||
* Coroutine scope tied to the lifetime of this session, to be used for work that must stop when the session goes away.
|
||||
* It is cancelled when the session is logged out or otherwise destroyed, so never use it for work that must outlive the session.
|
||||
*/
|
||||
val sessionCoroutineScope: CoroutineScope
|
||||
|
||||
/** The users the logged in user has ignored; starts empty and is updated by the SDK, including with an initial value. */
|
||||
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
|
||||
|
||||
/** Notifies of membership changes the current user makes, so that callers can react to leaving or joining a room. */
|
||||
val roomMembershipObserver: RoomMembershipObserver
|
||||
|
||||
/** Emits updates about the live location beacons published by the current user. */
|
||||
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
|
||||
|
||||
/** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
|
||||
val contentScanner: ContentScanner?
|
||||
|
||||
/** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */
|
||||
val isShuttingDown: Boolean
|
||||
|
||||
/**
|
||||
* Returns the room with the given id if the user has joined it, along with its live [Timeline].
|
||||
* A new instance is created on each call and the caller owns it, so it must be closed once no longer needed.
|
||||
*
|
||||
* @param roomId the id of the room to open.
|
||||
* @return the room, or `null` if it is unknown to the client or the user is not joined.
|
||||
*/
|
||||
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
||||
|
||||
/**
|
||||
* Returns the room with the given id whatever the current membership is, without building a timeline.
|
||||
* As with [getJoinedRoom], a new instance is created on each call and must be closed by the caller.
|
||||
*
|
||||
* @param roomId the id of the room to open.
|
||||
* @return the room, or `null` if it is unknown to the client.
|
||||
*/
|
||||
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
||||
|
||||
/**
|
||||
* Looks for an existing direct message room shared with [userId].
|
||||
*
|
||||
* @param userId the other member of the direct message room.
|
||||
* @return the room id, or `null` if no direct message room exists with this user.
|
||||
*/
|
||||
suspend fun findDM(userId: UserId): Result<RoomId?>
|
||||
|
||||
/**
|
||||
* Returns the ids of every room the user has joined, as currently known by the client.
|
||||
* Invited, knocked and left rooms are excluded.
|
||||
*/
|
||||
suspend fun getJoinedRoomIds(): Result<Set<RoomId>>
|
||||
|
||||
/**
|
||||
* Adds [userId] to the ignore list of the account, which hides their events and makes [ignoredUsersFlow] emit.
|
||||
*
|
||||
* @param userId the user to ignore.
|
||||
*/
|
||||
suspend fun ignoreUser(userId: UserId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Removes [userId] from the ignore list of the account, which makes [ignoredUsersFlow] emit.
|
||||
*
|
||||
* @param userId the user to stop ignoring.
|
||||
*/
|
||||
suspend fun unignoreUser(userId: UserId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Creates a room, applying the app's default power levels on top of [createRoomParams].
|
||||
* Waits a short while for the new room to come back through sync, but a timeout there is only logged: the result is still a success.
|
||||
*
|
||||
* @param createRoomParams the name, topic, preset, invitees and other settings of the room to create.
|
||||
*/
|
||||
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
|
||||
|
||||
/**
|
||||
* Creates a direct message room with [userId], as a private, invite-only room with the user already invited.
|
||||
* This does not check whether such a room already exists; use [findDM] first if you want to reuse one.
|
||||
*
|
||||
* @param userId the user to start the direct message with.
|
||||
* @param isEncrypted whether the room should have end-to-end encryption enabled.
|
||||
*/
|
||||
suspend fun createDM(userId: UserId, isEncrypted: Boolean): Result<RoomId>
|
||||
|
||||
/**
|
||||
* Fetches the current profile of any user from the homeserver, bypassing the local cache.
|
||||
*
|
||||
* @param userId the user whose profile is requested.
|
||||
*/
|
||||
suspend fun getProfile(userId: UserId): Result<MatrixUser>
|
||||
|
||||
/**
|
||||
* Searches the homeserver's user directory.
|
||||
*
|
||||
* @param searchTerm the text to look for in user ids and display names.
|
||||
* @param limit the maximum number of results to return.
|
||||
*/
|
||||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||
|
||||
/**
|
||||
* Changes the display name of the logged in user on the homeserver.
|
||||
*
|
||||
* @param displayName the new display name.
|
||||
*/
|
||||
suspend fun setDisplayName(displayName: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Uploads [data] as the new avatar of the logged in user.
|
||||
*
|
||||
* @param mimeType the MIME type of the image, for instance `image/jpeg`.
|
||||
* @param data the raw bytes of the image.
|
||||
*/
|
||||
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
|
||||
/** Removes the avatar of the logged in user. */
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Sets the `m.status` profile field of the logged in user (MSC4426), refreshing [userProfile] if the server does not push the change itself.
|
||||
*
|
||||
* @param status the status to publish.
|
||||
*/
|
||||
suspend fun setUserStatus(status: UserStatus): Result<Unit>
|
||||
|
||||
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
|
||||
suspend fun clearUserStatus(): Result<Unit>
|
||||
|
||||
/** Whether the homeserver advertises support for user status (MSC4426). */
|
||||
suspend fun isUserStatusSupported(): Result<Boolean>
|
||||
|
||||
/** Whether the homeserver advertises support for the Profiles sliding sync extension (MSC4262). */
|
||||
suspend fun isProfilesSlidingSyncExtensionSupported(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call.
|
||||
*
|
||||
* @param enabled whether the status should be maintained automatically.
|
||||
*/
|
||||
fun enableAutomaticCallStatus(enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Joins a room the client already knows about, then waits briefly for the new membership to arrive through sync.
|
||||
* The returned [RoomInfo] is `null` when that wait times out, which is not treated as a failure: the join itself succeeded.
|
||||
*
|
||||
* @param roomId the id of the room to join.
|
||||
*/
|
||||
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
|
||||
|
||||
/**
|
||||
* Joins a room by id or by alias, resolving it through [serverNames] when the client does not know it yet.
|
||||
* As with [joinRoom], the returned [RoomInfo] is `null` if the membership did not arrive through sync in time.
|
||||
*
|
||||
* @param roomIdOrAlias the id or the alias of the room to join.
|
||||
* @param serverNames servers to ask about the room, used when it cannot be resolved locally.
|
||||
*/
|
||||
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
||||
|
||||
/**
|
||||
* Sends a request to join a room whose join rule is knock, then waits briefly for the knocked membership to arrive through sync.
|
||||
* As with [joinRoom], the returned [RoomInfo] is `null` if that wait times out.
|
||||
*
|
||||
* @param roomIdOrAlias the id or the alias of the room to knock on.
|
||||
* @param message the reason shown to the room moderators; may be empty.
|
||||
* @param serverNames servers to ask about the room, used when it cannot be resolved locally.
|
||||
*/
|
||||
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
||||
|
||||
/**
|
||||
* Returns the number of bytes this session occupies on disk that [clearCache] would be able to reclaim.
|
||||
* This covers the cache directory, the state database and the message search index, but not the crypto database.
|
||||
*/
|
||||
suspend fun getCacheSize(): Long
|
||||
|
||||
/** Returns the size of each individual store the SDK maintains for this session, as reported by the SDK. */
|
||||
suspend fun getDatabaseSizes(): Result<SdkStoreSizes>
|
||||
|
||||
/**
|
||||
* Will close the client and delete the cache data.
|
||||
* Deletes the cached data of this session and destroys the client.
|
||||
* The instance is unusable afterwards, so callers are expected to restart the session, unlike with [logout] the session itself is kept.
|
||||
*/
|
||||
suspend fun clearCache()
|
||||
|
||||
@@ -119,29 +306,56 @@ interface MatrixClient {
|
||||
suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean)
|
||||
|
||||
/**
|
||||
* Retrieve the user profile, will also eventually emit a new value to [userProfile].
|
||||
* Retrieve the profile of the logged in user, emitting it to [userProfile] and persisting it in the local session store on success.
|
||||
*/
|
||||
suspend fun getUserProfile(): Result<MatrixUser>
|
||||
|
||||
/**
|
||||
* Returns the web URL where the user can manage their account on the authentication provider, or `null` if the homeserver exposes none.
|
||||
*
|
||||
* @param action an optional section of the account management page to open directly, such as changing the password.
|
||||
*/
|
||||
suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?>
|
||||
|
||||
/**
|
||||
* Uploads arbitrary content to the homeserver's media repository.
|
||||
*
|
||||
* @param mimeType the MIME type of the content being uploaded.
|
||||
* @param data the raw bytes to upload.
|
||||
* @return the `mxc://` URI the content is now available at.
|
||||
*/
|
||||
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
||||
|
||||
/**
|
||||
* Get a room info flow for a given room ID.
|
||||
* The flow will emit a new value whenever the room info is updated.
|
||||
* The flow will emit Optional.empty item if the room is not found.
|
||||
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
|
||||
*
|
||||
* @param roomId the id of the room to observe.
|
||||
*/
|
||||
fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>>
|
||||
|
||||
/**
|
||||
* Whether [userId] is the logged in user, `false` for a `null` value.
|
||||
*
|
||||
* @param userId the user id to compare against this session's user.
|
||||
*/
|
||||
fun isMe(userId: UserId?) = userId == sessionId
|
||||
|
||||
/**
|
||||
* Adds a room to the account's list of recently visited rooms, which backs the room suggestions when sharing or forwarding.
|
||||
*
|
||||
* @param roomId the id of the room that was just visited.
|
||||
*/
|
||||
suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit>
|
||||
|
||||
/** Returns the recently visited rooms of the account, most recent first. */
|
||||
suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>>
|
||||
|
||||
/**
|
||||
* Resolves the given room alias to a roomID (and a list of servers), if possible.
|
||||
* @param roomAlias the room alias to resolve
|
||||
* @return the resolved room alias if any, an empty result if not found,or an error if the resolution failed.
|
||||
*
|
||||
* @param roomAlias the room alias to resolve.
|
||||
* @return the resolved room alias if any, an empty result if not found, or an error if the resolution failed.
|
||||
*/
|
||||
suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<Optional<ResolvedRoomAlias>>
|
||||
|
||||
@@ -152,6 +366,8 @@ interface MatrixClient {
|
||||
* event with it failed (e.g. sending an event via the Timeline),
|
||||
* so it's required to manually re-enable it as soon as
|
||||
* connectivity is back on the device.
|
||||
*
|
||||
* @param enabled whether the send queues of every room should be enabled.
|
||||
*/
|
||||
suspend fun setAllSendQueuesEnabled(enabled: Boolean)
|
||||
|
||||
@@ -167,13 +383,11 @@ interface MatrixClient {
|
||||
*/
|
||||
fun userIdServerName(): String
|
||||
|
||||
/**
|
||||
* Execute generic GET requests through the SDKs internal HTTP client.
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<ByteArray>
|
||||
|
||||
/**
|
||||
* Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet.
|
||||
*
|
||||
* @param roomIdOrAlias the id or the alias of the room to preview.
|
||||
* @param serverNames servers to ask about the room, used when it cannot be resolved locally.
|
||||
*/
|
||||
suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<NotJoinedRoom>
|
||||
|
||||
@@ -182,11 +396,21 @@ interface MatrixClient {
|
||||
*/
|
||||
suspend fun currentSlidingSyncVersion(): Result<SlidingSyncVersion>
|
||||
|
||||
/** Whether this account can be deactivated from the app, which depends on the authentication method used by the homeserver. */
|
||||
fun canDeactivateAccount(): Boolean
|
||||
|
||||
/**
|
||||
* Deactivates the account on the homeserver, then destroys this client and deletes every local trace of the session.
|
||||
* The password is only sent if the homeserver asks for re-authentication, and on failure the session is left untouched.
|
||||
*
|
||||
* @param password the account password, required to confirm the deactivation.
|
||||
* @param eraseData whether the homeserver should also erase the messages the user sent.
|
||||
*/
|
||||
suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Check if the user can report a room.
|
||||
* Whether the homeserver supports reporting a whole room, rather than individual events.
|
||||
* Returns `false` if the capability cannot be determined.
|
||||
*/
|
||||
suspend fun canReportRoom(): Boolean
|
||||
|
||||
@@ -207,14 +431,34 @@ interface MatrixClient {
|
||||
|
||||
/**
|
||||
* Adds an emoji to the list of recent emoji reactions for this account.
|
||||
*
|
||||
* @param emoji the emoji that was just used.
|
||||
*/
|
||||
suspend fun addRecentEmoji(emoji: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Returns the raw JSON content of the global account data event of type [eventType], or null if it is not set.
|
||||
*
|
||||
* @param eventType the Matrix event type of the account data to read, for instance `m.direct`.
|
||||
*/
|
||||
suspend fun getAccountData(eventType: String): Result<String?>
|
||||
|
||||
/**
|
||||
* Sets the global account data event of type [eventType] to the raw JSON [content].
|
||||
*
|
||||
* @param eventType the Matrix event type of the account data to write.
|
||||
* @param content the new content, as a raw JSON object.
|
||||
*/
|
||||
suspend fun setAccountData(eventType: String, content: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Marks the room with the provided [roomId] as read, sending a fully read receipt for [eventId].
|
||||
*
|
||||
* This method should be used with caution as providing the [eventId] ourselves can result in incorrect read receipts.
|
||||
* Use [Timeline.markAsRead] instead when possible.
|
||||
*
|
||||
* @param roomId the id of the room to mark as read.
|
||||
* @param eventId the event up to which the room is considered read.
|
||||
*/
|
||||
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit>
|
||||
|
||||
@@ -241,19 +485,16 @@ interface MatrixClient {
|
||||
|
||||
/**
|
||||
* Performs a database optimization that should flush cached data and improve performance.
|
||||
* A periodic background job already calls this for every session, so callers rarely need to trigger it by hand.
|
||||
*/
|
||||
suspend fun performDatabaseVacuum(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Returns the URL of the map style configured on the server, if any.
|
||||
*/
|
||||
suspend fun getMapStyleUrl(): Result<String?>
|
||||
|
||||
/**
|
||||
* Resets the cached client `well-known` config by the SDK.
|
||||
*/
|
||||
suspend fun resetWellKnownConfig(): Result<Unit>
|
||||
|
||||
/** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */
|
||||
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* An abstraction over the Matrix SDK's `ClientBuilder` to allow sharing it with other modules without exposing the SDK directly.
|
||||
*/
|
||||
interface MatrixClientBuilder
|
||||
+14
-6
@@ -10,18 +10,26 @@ package io.element.android.libraries.matrix.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* In-memory cache of the [MatrixClient] of every logged in session.
|
||||
*
|
||||
* Prefer injecting the [MatrixClient] directly: this provider is meant for the entry points that only know a [SessionId], such as push handling and workers.
|
||||
*/
|
||||
interface MatrixClientProvider {
|
||||
/**
|
||||
* Can be used to get or restore a MatrixClient with the given [SessionId].
|
||||
* If a [MatrixClient] is already in memory, it'll return it. Otherwise it'll try to restore one.
|
||||
* Most of the time you want to use injected constructor instead of retrieving a MatrixClient with this provider.
|
||||
* Returns the cached [MatrixClient] for [sessionId], restoring the session from storage and starting its sync if it is not in memory yet.
|
||||
* Concurrent calls are serialised, so only one restore happens per session.
|
||||
*
|
||||
* @param sessionId the [SessionId] of the session to get or restore.
|
||||
* @return the client, or a failure if the session could not be restored.
|
||||
*/
|
||||
suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient>
|
||||
|
||||
/**
|
||||
* Can be used to retrieve an existing [MatrixClient] with the given [SessionId].
|
||||
* @param sessionId the [SessionId] of the [MatrixClient] to retrieve.
|
||||
* @return the [MatrixClient] if it exists.
|
||||
* Returns the [MatrixClient] for [sessionId] only if it is already in memory, without attempting to restore it.
|
||||
*
|
||||
* @param sessionId the [SessionId] of the session to retrieve.
|
||||
* @return the cached client, or `null` if that session is not currently held in memory.
|
||||
*/
|
||||
fun getOrNull(sessionId: SessionId): MatrixClient?
|
||||
}
|
||||
|
||||
+8
@@ -8,6 +8,14 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* Exposes build information about the bundled `matrix-rust-sdk`.
|
||||
*
|
||||
* Mostly used to identify the exact SDK revision in bug reports and in the user agent.
|
||||
*/
|
||||
interface SdkMetadata {
|
||||
/**
|
||||
* The full git SHA of the commit the bundled `matrix-rust-sdk` was built from.
|
||||
*/
|
||||
val sdkGitSha: String
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
import java.lang.AutoCloseable
|
||||
|
||||
/**
|
||||
* A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs.
|
||||
*
|
||||
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
|
||||
* with it, such as temporary files.
|
||||
*/
|
||||
interface TemporaryMatrixClient : ClientUrlContentFetcher, AutoCloseable
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* Factory to create a temporary [TemporaryMatrixClient] for a given home server URL.
|
||||
*/
|
||||
interface TemporaryMatrixClientFactory {
|
||||
/**
|
||||
* Builds an unauthenticated client pointing at [serverName], backed by a fresh set of temporary session directories.
|
||||
* The caller owns the returned client and must close it to delete those directories.
|
||||
*
|
||||
* @param serverName the domain of the public-facing server for the actual homeserver
|
||||
*/
|
||||
suspend fun create(serverName: String): Result<TemporaryMatrixClient>
|
||||
}
|
||||
+8
@@ -9,6 +9,14 @@ package io.element.android.libraries.matrix.api.analytics
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Reads the on-disk size of a session's SDK stores, so they can be reported as analytics.
|
||||
*/
|
||||
fun interface GetDatabaseSizesUseCase {
|
||||
/**
|
||||
* Returns the size of each store of the given session.
|
||||
*
|
||||
* @param sessionId the session whose stores are measured.
|
||||
*/
|
||||
operator fun invoke(sessionId: SessionId): Result<SdkStoreSizes>
|
||||
}
|
||||
|
||||
+40
-1
@@ -16,23 +16,41 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Creates and restores sessions: password login, OAuth login, QR code login, and importing a session from another client.
|
||||
*
|
||||
* [setHomeserver] must be called before any login attempt, since it is what tells the SDK which server to authenticate against.
|
||||
* Every successful login stores the session and notifies the listeners registered through [listenToNewMatrixClients].
|
||||
*/
|
||||
interface MatrixAuthenticationService {
|
||||
/**
|
||||
* Restore a session from a [sessionId].
|
||||
* Do not restore anything it the access token is not valid anymore.
|
||||
* Generally this method should not be used directly, prefer using [MatrixClientProvider.getOrRestore] instead.
|
||||
*
|
||||
* @param sessionId the session to restore.
|
||||
*/
|
||||
suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient>
|
||||
|
||||
/**
|
||||
* Set the homeserver to use for authentication, and return its details.
|
||||
*
|
||||
* @param homeserver the homeserver URL or server name to authenticate against.
|
||||
*/
|
||||
suspend fun setHomeserver(homeserver: String): Result<MatrixHomeServerDetails>
|
||||
|
||||
/**
|
||||
* Logs in with a password on the homeserver previously set through [setHomeserver].
|
||||
*
|
||||
* @param username the user id or its local part.
|
||||
* @param password the account password.
|
||||
*/
|
||||
suspend fun login(username: String, password: String): Result<SessionId>
|
||||
|
||||
/**
|
||||
* Import a session that was created using another client, for instance Element Web.
|
||||
*
|
||||
* @param externalSession the tokens and identifiers of the session to import.
|
||||
*/
|
||||
suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId>
|
||||
|
||||
@@ -42,6 +60,9 @@ interface MatrixAuthenticationService {
|
||||
|
||||
/**
|
||||
* Get the OAuth url to display to the user.
|
||||
*
|
||||
* @param prompt whether the authentication server should show the sign-in or the account-creation page.
|
||||
* @param loginHint the user identifier to pre-fill on that page, or `null` to leave it empty.
|
||||
*/
|
||||
suspend fun getOAuthUrl(
|
||||
prompt: OAuthPrompt,
|
||||
@@ -55,11 +76,17 @@ interface MatrixAuthenticationService {
|
||||
|
||||
/**
|
||||
* Set the existing data about Element Classic session, if any.
|
||||
*
|
||||
* @param session the session found in a local Element Classic install, or `null` when there is none to migrate.
|
||||
*/
|
||||
fun setElementClassicSession(session: ElementClassicSession?)
|
||||
|
||||
/**
|
||||
* Check if the provided secrets from Element Classic session contain a key backup.
|
||||
*
|
||||
* @param userId the user the secrets belong to.
|
||||
* @param secrets the raw secrets exported from Element Classic.
|
||||
* @param backupInfo the raw backup description exported alongside them.
|
||||
*/
|
||||
fun doSecretsContainBackupKey(
|
||||
userId: UserId,
|
||||
@@ -69,11 +96,23 @@ interface MatrixAuthenticationService {
|
||||
|
||||
/**
|
||||
* Attempt to log in using the [callbackUrl] provided by the OAuth page.
|
||||
*
|
||||
* @param callbackUrl the redirect URL the authentication server sent the user back to, carrying the authorization code.
|
||||
*/
|
||||
suspend fun loginWithOAuth(callbackUrl: String): Result<SessionId>
|
||||
|
||||
/**
|
||||
* Logs in by acting on a QR code shown by an already signed in device, reporting each step of the exchange as it happens.
|
||||
*
|
||||
* @param qrCodeData the decoded QR code, obtained from [io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory].
|
||||
* @param progress called on every step of the flow, so the UI can follow along.
|
||||
*/
|
||||
suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId>
|
||||
|
||||
/** Listen to new Matrix clients being created on authentication. */
|
||||
/**
|
||||
* Listen to new Matrix clients being created on authentication.
|
||||
*
|
||||
* @param lambda called with each client created by a successful login or session restore.
|
||||
*/
|
||||
fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit)
|
||||
}
|
||||
|
||||
+2
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth
|
||||
import io.element.android.libraries.matrix.api.BuildConfig
|
||||
|
||||
object OAuthConfig {
|
||||
// This *can* be null if the build time config field is not set
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
|
||||
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
|
||||
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
|
||||
|
||||
+4
@@ -8,6 +8,10 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
/**
|
||||
* Provides the redirect URL registered for OAuth login, which the authentication server sends the user back to once they have signed in.
|
||||
*/
|
||||
interface OAuthRedirectUrlProvider {
|
||||
/** Returns the redirect URL, built from the app's custom URL scheme so that the system hands the callback back to the app. */
|
||||
fun provide(): String
|
||||
}
|
||||
|
||||
+6
@@ -8,6 +8,12 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth.qrlogin
|
||||
|
||||
/**
|
||||
* The decoded content of a login QR code, as produced by [MatrixQrCodeLoginDataFactory].
|
||||
*
|
||||
* The underlying data is opaque to the app: it is passed back to the SDK to perform the login.
|
||||
*/
|
||||
interface MatrixQrCodeLoginData {
|
||||
/** The homeserver advertised by the QR code, or `null` when it does not carry one. */
|
||||
fun serverName(): String?
|
||||
}
|
||||
|
||||
+8
@@ -8,6 +8,14 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth.qrlogin
|
||||
|
||||
/**
|
||||
* Decodes the raw bytes of a scanned QR code into login data the SDK can use.
|
||||
*/
|
||||
interface MatrixQrCodeLoginDataFactory {
|
||||
/**
|
||||
* Parses the scanned bytes, failing with a [QrCodeDecodeException] when they are not a valid Matrix login QR code.
|
||||
*
|
||||
* @param data the raw content read from the QR code.
|
||||
*/
|
||||
fun parseQrCodeData(data: ByteArray): Result<MatrixQrCodeLoginData>
|
||||
}
|
||||
|
||||
+9
@@ -8,6 +8,15 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.core
|
||||
|
||||
/**
|
||||
* Reports the progress of a long running transfer, such as a media upload or download.
|
||||
*/
|
||||
interface ProgressCallback {
|
||||
/**
|
||||
* Called as the transfer advances, possibly many times and from a background thread.
|
||||
*
|
||||
* @param current the number of bytes transferred so far.
|
||||
* @param total the total number of bytes to transfer.
|
||||
*/
|
||||
fun onProgress(current: Long, total: Long)
|
||||
}
|
||||
|
||||
+4
@@ -8,6 +8,10 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.core
|
||||
|
||||
/**
|
||||
* A handle on an event sitting in a room's send queue, obtained from a timeline item that failed to send.
|
||||
*/
|
||||
fun interface SendHandle {
|
||||
/** Puts the event back in the send queue for another attempt. */
|
||||
suspend fun retry(): Result<Unit>
|
||||
}
|
||||
|
||||
+28
-1
@@ -14,13 +14,32 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Gives access to the end-to-end encryption state of the session: key backup, recovery, and the trust of other users' identities.
|
||||
*/
|
||||
interface EncryptionService {
|
||||
/**
|
||||
* The state of the room key backup on the server.
|
||||
* Reports [BackupState.WAITING_FOR_SYNC] for as long as the session is not actively syncing, since the real state is unknown until then.
|
||||
*/
|
||||
val backupStateStateFlow: StateFlow<BackupState>
|
||||
|
||||
/**
|
||||
* The state of recovery, i.e. whether the secrets needed to restore the keys on a new device are available.
|
||||
* As with [backupStateStateFlow], it reports a waiting-for-sync state until the session is syncing.
|
||||
*/
|
||||
val recoveryStateStateFlow: StateFlow<RecoveryState>
|
||||
|
||||
/** Progress of the [enableRecovery] call currently in flight, to be observed while that call is running. */
|
||||
val enableRecoveryProgressStateFlow: StateFlow<EnableRecoveryProgress>
|
||||
|
||||
/** Whether this is the only device of the account, which matters because logging out of it can make the room keys unrecoverable. */
|
||||
val isLastDevice: StateFlow<Boolean>
|
||||
|
||||
/** Whether the account has another device this one could be verified against; it stays loading until the first answer arrives. */
|
||||
val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>>
|
||||
|
||||
/** Enables the room key backup on the server, so that the keys survive losing every device. */
|
||||
suspend fun enableBackups(): Result<Unit>
|
||||
|
||||
/**
|
||||
@@ -39,12 +58,16 @@ interface EncryptionService {
|
||||
*/
|
||||
suspend fun resetRecoveryKey(): Result<String>
|
||||
|
||||
/** Disables recovery, which deletes the secrets from server-side storage; the key backup itself is left in place. */
|
||||
suspend fun disableRecovery(): Result<Unit>
|
||||
|
||||
/** Whether a room key backup currently exists on the server, whether or not this device is the one using it. */
|
||||
suspend fun doesBackupExistOnServer(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Note: accept both recoveryKey and passphrase.
|
||||
* Restores the secrets from server-side storage, which unlocks the room key backup for this device.
|
||||
*
|
||||
* @param recoveryKey the recovery key, or the passphrase it was derived from; both are accepted.
|
||||
*/
|
||||
suspend fun recover(recoveryKey: String): Result<Unit>
|
||||
|
||||
@@ -72,6 +95,8 @@ interface EncryptionService {
|
||||
|
||||
/**
|
||||
* Remember this identity, ensuring it does not result in a pin violation.
|
||||
*
|
||||
* @param userId the user whose current identity should be pinned.
|
||||
*/
|
||||
suspend fun pinUserIdentity(userId: UserId): Result<Unit>
|
||||
|
||||
@@ -81,6 +106,8 @@ interface EncryptionService {
|
||||
* Useful when a user that was verified is not anymore, but it is not
|
||||
* possible to re-verify immediately. This allows to restore communication by reverting the
|
||||
* user trust from verified to TOFU verified.
|
||||
*
|
||||
* @param userId the user whose verification should be withdrawn.
|
||||
*/
|
||||
suspend fun withdrawVerification(userId: UserId): Result<Unit>
|
||||
|
||||
|
||||
+3
@@ -7,6 +7,9 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.linknewdevice
|
||||
|
||||
/**
|
||||
* Lets the user confirm the two-digit check code displayed by the device being linked, which proves the channel is not being intercepted.
|
||||
*/
|
||||
interface CheckCodeSender {
|
||||
/**
|
||||
* Validates the given [code]. Returns true if the code is valid, false otherwise.
|
||||
|
||||
+8
@@ -7,7 +7,15 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.linknewdevice
|
||||
|
||||
/**
|
||||
* Lets the user confirm or refuse a device linking request once they have approved it in the browser.
|
||||
*
|
||||
* Handed out with the step that is waiting for authorisation; exactly one of the two methods is meant to be called.
|
||||
*/
|
||||
interface ContinuationMessageSender {
|
||||
/** Tells the other device that the user refused the linking, which ends the flow. */
|
||||
suspend fun cancel(): Result<Unit>
|
||||
|
||||
/** Tells the other device that the user approved the linking, so the flow can carry on. */
|
||||
suspend fun confirm(): Result<Unit>
|
||||
}
|
||||
|
||||
+14
@@ -10,8 +10,22 @@ package io.element.android.libraries.matrix.api.linknewdevice
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Drives the linking of a new device that cannot scan a QR code, such as a desktop: this device scans the code the other one displays.
|
||||
*
|
||||
* Progress is reported entirely through [linkDesktopStep]; there is one handler per attempt, obtained from
|
||||
* [io.element.android.libraries.matrix.api.MatrixClient.createLinkDesktopHandler].
|
||||
*/
|
||||
interface LinkDesktopHandler {
|
||||
/** Each step of the flow, from establishing the secure channel to syncing the secrets to the new device. */
|
||||
val linkDesktopStep: StateFlow<LinkDesktopStep>
|
||||
|
||||
/**
|
||||
* Runs the whole flow from the QR code just scanned, suspending until it finishes or fails.
|
||||
* A code that cannot be decoded is reported as an invalid-QR-code step rather than as a thrown error.
|
||||
*
|
||||
* @param data the raw content read from the QR code shown by the other device.
|
||||
*/
|
||||
suspend fun handleScannedQrCode(data: ByteArray)
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -9,8 +9,17 @@ package io.element.android.libraries.matrix.api.linknewdevice
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Drives the linking of a new device that is able to scan a QR code, by showing one on this already signed in device.
|
||||
*
|
||||
* Progress is reported entirely through [linkMobileStep]; there is one handler per attempt, obtained from
|
||||
* [io.element.android.libraries.matrix.api.MatrixClient.createLinkMobileHandler].
|
||||
*/
|
||||
interface LinkMobileHandler {
|
||||
/** Each step of the flow, from generating the QR code to syncing the secrets to the new device. */
|
||||
val linkMobileStep: Flow<LinkMobileStep>
|
||||
|
||||
/** Runs the whole flow, suspending until it finishes or fails; the outcome is reported through [linkMobileStep]. */
|
||||
suspend fun start()
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.media
|
||||
|
||||
/**
|
||||
* Fetches media content from the homeserver, decrypting it on the way when the room is encrypted.
|
||||
*/
|
||||
interface MatrixMediaLoader {
|
||||
/**
|
||||
* @param source to fetch the content for.
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ import java.io.File
|
||||
* When closed the file will be removed from the disk unless [persist] has been used.
|
||||
*/
|
||||
interface MediaFile : Closeable {
|
||||
/** The absolute path of the file on disk; see [toFile] to get a [File] instead. */
|
||||
fun path(): String
|
||||
|
||||
/**
|
||||
|
||||
+9
@@ -10,6 +10,11 @@ package io.element.android.libraries.matrix.api.media
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Reads and writes the account settings that decide whether media previews and invite avatars are shown.
|
||||
*
|
||||
* These are account data settings shared across clients, so the local value is only updated once the server has accepted the change.
|
||||
*/
|
||||
interface MediaPreviewService {
|
||||
/**
|
||||
* Will fetch the media preview config from the server.
|
||||
@@ -24,11 +29,15 @@ interface MediaPreviewService {
|
||||
|
||||
/**
|
||||
* Set the media preview display policy. This will update the value on the server and update the local value when successful.
|
||||
*
|
||||
* @param mediaPreviewValue whether media previews are shown always, never, or only in private rooms.
|
||||
*/
|
||||
suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit>
|
||||
|
||||
/**
|
||||
* Set the invite avatars display policy. This will update the value on the server and update the local value when successful.
|
||||
*
|
||||
* @param hide true to hide the avatars of users and rooms the user has been invited by.
|
||||
*/
|
||||
suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit>
|
||||
}
|
||||
|
||||
+3
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.mxc
|
||||
|
||||
/**
|
||||
* Helpers to work with Matrix Content (`mxc://`) URIs.
|
||||
*/
|
||||
interface MxcTools {
|
||||
/**
|
||||
* Sanitizes an mxcUri to be used as a relative file path.
|
||||
|
||||
+3
@@ -24,6 +24,9 @@ typealias GetNotificationDataResult = Result<Map<EventId, Result<NotificationDat
|
||||
interface NotificationService {
|
||||
/**
|
||||
* Fetch notifications for the specified event ids in the given rooms.
|
||||
* Each event is resolved independently, so a partial answer is normal: see [GetNotificationDataResult] for how the two failure levels differ.
|
||||
*
|
||||
* @param ids the events to resolve, grouped by the room they belong to.
|
||||
*/
|
||||
suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): GetNotificationDataResult
|
||||
}
|
||||
|
||||
+88
-2
@@ -11,28 +11,114 @@ package io.element.android.libraries.matrix.api.notificationsettings
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* Reads and writes the push rules of the account, which decide what generates a notification.
|
||||
*
|
||||
* Rules are account data shared across clients, so a change made here is visible on the user's other devices.
|
||||
* Several methods take the encryption and one-to-one nature of a room, because Matrix keeps a separate default rule for each combination.
|
||||
*/
|
||||
interface NotificationSettingsService {
|
||||
/**
|
||||
* State of the current room notification settings flow ([RoomNotificationSettingsState.Unknown] if not started).
|
||||
* Emits every time the push rules change, whether from this client or another one, without saying what changed.
|
||||
* Callers are expected to re-read the values they display; only the most recent signal is buffered.
|
||||
*/
|
||||
val notificationSettingsChangeFlow: SharedFlow<Unit>
|
||||
|
||||
/**
|
||||
* Returns the notification settings that apply to one room, which may come from a room specific rule or from the matching default.
|
||||
*
|
||||
* @param roomId the room to read the settings of.
|
||||
* @param isEncrypted whether that room is encrypted, which selects the relevant default rule.
|
||||
* @param isOneToOne whether that room is a direct message, which selects the relevant default rule.
|
||||
*/
|
||||
suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings>
|
||||
|
||||
/**
|
||||
* Returns the default notification mode used by rooms that have no rule of their own.
|
||||
*
|
||||
* @param isEncrypted whether to read the default for encrypted rooms.
|
||||
* @param isOneToOne whether to read the default for direct messages.
|
||||
*/
|
||||
suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode>
|
||||
|
||||
/**
|
||||
* Changes the default notification mode for a whole category of rooms.
|
||||
*
|
||||
* @param isEncrypted whether to change the default for encrypted rooms.
|
||||
* @param mode the new default mode.
|
||||
* @param isDM whether to change the default for direct messages.
|
||||
*/
|
||||
suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isDM: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Sets a room specific notification mode, which from then on overrides the default for that room.
|
||||
*
|
||||
* @param roomId the room to change.
|
||||
* @param mode the new mode for that room.
|
||||
*/
|
||||
suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit>
|
||||
|
||||
/**
|
||||
* Removes the room specific rule so the room follows the matching default again.
|
||||
*
|
||||
* @param roomId the room to reset.
|
||||
*/
|
||||
suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Mutes a room, which is a room specific rule like any other and is undone by [unmuteRoom].
|
||||
*
|
||||
* @param roomId the room to mute.
|
||||
*/
|
||||
suspend fun muteRoom(roomId: RoomId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Unmutes a room, restoring either its previous room specific mode or the matching default.
|
||||
*
|
||||
* @param roomId the room to unmute.
|
||||
* @param isEncrypted whether that room is encrypted, which selects the relevant default rule.
|
||||
* @param isOneToOne whether that room is a direct message, which selects the relevant default rule.
|
||||
*/
|
||||
suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<Unit>
|
||||
|
||||
/** Whether being mentioned through `@room` generates a notification. */
|
||||
suspend fun isRoomMentionEnabled(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Sets whether being mentioned through `@room` generates a notification.
|
||||
*
|
||||
* @param enabled true to be notified of `@room` mentions.
|
||||
*/
|
||||
suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit>
|
||||
|
||||
/** Whether incoming calls generate a notification. */
|
||||
suspend fun isCallEnabled(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Sets whether incoming calls generate a notification.
|
||||
*
|
||||
* @param enabled true to be notified of incoming calls.
|
||||
*/
|
||||
suspend fun setCallEnabled(enabled: Boolean): Result<Unit>
|
||||
|
||||
/** Whether room invitations generate a notification. */
|
||||
suspend fun isInviteForMeEnabled(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Sets whether room invitations generate a notification.
|
||||
*
|
||||
* @param enabled true to be notified of invitations.
|
||||
*/
|
||||
suspend fun setInviteForMeEnabled(enabled: Boolean): Result<Unit>
|
||||
|
||||
/** The rooms that have an enabled rule of their own rather than following the defaults. */
|
||||
suspend fun getRoomsWithUserDefinedRules(): Result<List<RoomId>>
|
||||
|
||||
/** Whether the homeserver is able to push encrypted events to the device, which decides how much a notification can show before decryption. */
|
||||
suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean>
|
||||
|
||||
/** The push rules as raw JSON, for debugging and bug reports; `null` when the server returned none. */
|
||||
suspend fun getRawPushRules(): Result<String?>
|
||||
}
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ interface MatrixToConverter {
|
||||
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
|
||||
* - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
|
||||
* - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
|
||||
*
|
||||
* @param uri the uri to convert.
|
||||
* @return the equivalent matrix.to uri, or `null` when the input is not a permalink this converter recognises.
|
||||
*/
|
||||
fun convert(uri: Uri): Uri?
|
||||
}
|
||||
|
||||
+14
@@ -11,8 +11,22 @@ package io.element.android.libraries.matrix.api.permalink
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Builds `matrix.to` permalinks, which are the shareable links to a user or a room; see [PermalinkParser] for the other direction.
|
||||
*/
|
||||
interface PermalinkBuilder {
|
||||
/**
|
||||
* Builds a permalink to a user, failing with [PermalinkBuilderError.InvalidData] when the id is malformed.
|
||||
*
|
||||
* @param userId the user to link to.
|
||||
*/
|
||||
fun permalinkForUser(userId: UserId): Result<String>
|
||||
|
||||
/**
|
||||
* Builds a permalink to a room from its alias, failing with [PermalinkBuilderError.InvalidData] when the alias is malformed.
|
||||
*
|
||||
* @param roomAlias the room alias to link to.
|
||||
*/
|
||||
fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String>
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -18,7 +18,10 @@ package io.element.android.libraries.matrix.api.permalink
|
||||
interface PermalinkParser {
|
||||
/**
|
||||
* Turns a uri string to a [PermalinkData].
|
||||
* A uri that is not a Matrix permalink is reported as a fallback link rather than as an error, so this never throws.
|
||||
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
|
||||
*
|
||||
* @param uriString the uri to parse.
|
||||
*/
|
||||
fun parse(uriString: String): PermalinkData
|
||||
}
|
||||
|
||||
+14
@@ -8,7 +8,21 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.pusher
|
||||
|
||||
/**
|
||||
* Registers and removes the pusher of this session, i.e. the HTTP gateway the homeserver sends push notifications to.
|
||||
*/
|
||||
interface PushersService {
|
||||
/**
|
||||
* Registers or updates the pusher; calling it again with the same push key replaces the existing registration.
|
||||
*
|
||||
* @param setHttpPusherData the gateway URL, the push key and the other details the homeserver needs to reach the device.
|
||||
*/
|
||||
suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit>
|
||||
|
||||
/**
|
||||
* Removes the pusher, so the homeserver stops sending push notifications to this device.
|
||||
*
|
||||
* @param unsetHttpPusherData the push key and app id identifying the registration to remove.
|
||||
*/
|
||||
suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result<Unit>
|
||||
}
|
||||
|
||||
+43
-1
@@ -66,21 +66,30 @@ interface BaseRoom : Closeable {
|
||||
*/
|
||||
fun isDm() = roomInfoFlow.value.isDm
|
||||
|
||||
/**
|
||||
* The room this one replaced when it was upgraded, or `null` if this room has no predecessor.
|
||||
* Errors are swallowed and reported as `null`.
|
||||
*/
|
||||
fun predecessorRoom(): PredecessorRoom?
|
||||
|
||||
/**
|
||||
* Try to load the room members and update the membersFlow.
|
||||
* The first call may serve members from the local cache before the server response arrives; later calls always go to the server.
|
||||
*/
|
||||
suspend fun updateMembers()
|
||||
|
||||
/**
|
||||
* Get the members of the room. Note: generally this should not be used, please use
|
||||
* [membersStateFlow] and [updateMembers] instead.
|
||||
*
|
||||
* @param limit the maximum number of members to return.
|
||||
*/
|
||||
suspend fun getMembers(limit: Int = 5): Result<List<RoomMember>>
|
||||
|
||||
/**
|
||||
* Will return an updated member or an error.
|
||||
*
|
||||
* @param userId the member to fetch.
|
||||
*/
|
||||
suspend fun getUpdatedMember(userId: UserId): Result<RoomMember>
|
||||
|
||||
@@ -103,6 +112,8 @@ interface BaseRoom : Closeable {
|
||||
|
||||
/**
|
||||
* Gets the role of the user with the provided [userId] in the room.
|
||||
*
|
||||
* @param userId the member whose role is requested.
|
||||
*/
|
||||
suspend fun userRole(userId: UserId): Result<RoomMember.Role>
|
||||
|
||||
@@ -113,11 +124,15 @@ interface BaseRoom : Closeable {
|
||||
|
||||
/**
|
||||
* Gets the display name of the user with the provided [userId] in the room.
|
||||
*
|
||||
* @param userId the member whose room specific display name is requested.
|
||||
*/
|
||||
suspend fun userDisplayName(userId: UserId): Result<String?>
|
||||
|
||||
/**
|
||||
* Gets the avatar of the user with the provided [userId] in the room.
|
||||
*
|
||||
* @param userId the member whose room specific avatar is requested.
|
||||
*/
|
||||
suspend fun userAvatarUrl(userId: UserId): Result<String?>
|
||||
|
||||
@@ -138,6 +153,8 @@ interface BaseRoom : Closeable {
|
||||
|
||||
/**
|
||||
* Sets the room as favorite or not, based on the [isFavorite] parameter.
|
||||
*
|
||||
* @param isFavorite true to mark the room as favorite, false to remove the flag.
|
||||
*/
|
||||
suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit>
|
||||
|
||||
@@ -176,22 +193,29 @@ interface BaseRoom : Closeable {
|
||||
suspend fun getRoomVisibility(): Result<RoomVisibility>
|
||||
|
||||
/**
|
||||
* Returns the visibility for this room in the room directory, fetching it from the homeserver if needed.
|
||||
* Returns whether this room is encrypted, based on the latest encryption state known to the SDK rather than on the cached [RoomInfo].
|
||||
*/
|
||||
suspend fun getUpdatedIsEncrypted(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Store the given `ComposerDraft` in the state store of this room.
|
||||
*
|
||||
* @param composerDraft the unsent message content to remember.
|
||||
* @param threadRoot the thread the draft belongs to, or `null` for the main timeline.
|
||||
*/
|
||||
suspend fun saveComposerDraft(composerDraft: ComposerDraft, threadRoot: ThreadId?): Result<Unit>
|
||||
|
||||
/**
|
||||
* Retrieve the `ComposerDraft` stored in the state store for this room.
|
||||
*
|
||||
* @param threadRoot the thread whose draft is requested, or `null` for the main timeline.
|
||||
*/
|
||||
suspend fun loadComposerDraft(threadRoot: ThreadId?): Result<ComposerDraft?>
|
||||
|
||||
/**
|
||||
* Clear the `ComposerDraft` stored in the state store for this room.
|
||||
*
|
||||
* @param threadRoot the thread whose draft should be cleared, or `null` for the main timeline.
|
||||
*/
|
||||
suspend fun clearComposerDraft(threadRoot: ThreadId?): Result<Unit>
|
||||
|
||||
@@ -202,10 +226,27 @@ interface BaseRoom : Closeable {
|
||||
*/
|
||||
suspend fun reportRoom(reason: String?): Result<Unit>
|
||||
|
||||
/**
|
||||
* Declines the incoming call advertised by the given notification, letting the other devices of the room know.
|
||||
*
|
||||
* @param notificationEventId the id of the call notification event being declined.
|
||||
*/
|
||||
suspend fun declineCall(notificationEventId: EventId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Emits the id of each user who declines the call advertised by the given notification.
|
||||
* Used to stop ringing once the callee has declined from another device.
|
||||
*
|
||||
* @param notificationEventId the id of the call notification event to watch.
|
||||
*/
|
||||
suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId>
|
||||
|
||||
/**
|
||||
* Returns the id of the thread the given event belongs to, fetching the event from the server if it is not cached locally.
|
||||
*
|
||||
* @param eventId the event whose thread is requested.
|
||||
* @return the thread root id, or `null` if the event is not part of a thread.
|
||||
*/
|
||||
suspend fun threadRootIdForEvent(eventId: EventId): Result<ThreadId?>
|
||||
|
||||
/**
|
||||
@@ -213,5 +254,6 @@ interface BaseRoom : Closeable {
|
||||
*/
|
||||
fun destroy()
|
||||
|
||||
/** Same as [destroy], so that a room can be used with `use { }`. */
|
||||
override fun close() = destroy()
|
||||
}
|
||||
|
||||
+106
-2
@@ -28,11 +28,28 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A room the current user has joined, adding to [BaseRoom] everything that requires membership: sending events, moderation and room settings.
|
||||
*
|
||||
* An instance owns SDK resources, in particular [liveTimeline], so it must be closed once no longer needed.
|
||||
*/
|
||||
interface JoinedRoom : BaseRoom {
|
||||
/**
|
||||
* A counter incremented every time a synced event is received on [liveTimeline], so callers can refresh data derived from the room.
|
||||
* It carries no meaning beyond "something changed", and is only updated while it has subscribers.
|
||||
*/
|
||||
val syncUpdateFlow: StateFlow<Long>
|
||||
|
||||
/** The members currently typing in the room, never including the current user; starts as an empty list. */
|
||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||
|
||||
/** Emits when the verification state of other members' identities changes, for instance after a member resets their identity. */
|
||||
val identityStateChangesFlow: Flow<List<IdentityStateChange>>
|
||||
|
||||
/**
|
||||
* The notification settings that apply to this room.
|
||||
* Starts as [RoomNotificationSettingsState.Unknown]: [updateRoomNotificationSettings] must be called to populate it.
|
||||
*/
|
||||
val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState>
|
||||
|
||||
/**
|
||||
@@ -45,6 +62,7 @@ interface JoinedRoom : BaseRoom {
|
||||
*/
|
||||
val liveTimeline: Timeline
|
||||
|
||||
/** Gives access to the paginated list of threads started in this room. */
|
||||
val threadsListService: ThreadsListService
|
||||
|
||||
/**
|
||||
@@ -55,6 +73,14 @@ interface JoinedRoom : BaseRoom {
|
||||
createTimelineParams: CreateTimelineParams,
|
||||
): Result<Timeline>
|
||||
|
||||
/**
|
||||
* Replaces the content of an event previously sent by the current user, by sending an edit of it.
|
||||
*
|
||||
* @param eventId the event to edit.
|
||||
* @param body the new content, as plain text.
|
||||
* @param htmlBody the new content as HTML, or `null` when the message has no formatted version.
|
||||
* @param intentionalMentions the users and rooms this new content deliberately mentions.
|
||||
*/
|
||||
suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): Result<Unit>
|
||||
|
||||
/**
|
||||
@@ -63,12 +89,28 @@ interface JoinedRoom : BaseRoom {
|
||||
*/
|
||||
suspend fun typingNotice(isTyping: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Invites a user to this room.
|
||||
*
|
||||
* @param id the user to invite.
|
||||
*/
|
||||
suspend fun inviteUserById(id: UserId): Result<Unit>
|
||||
|
||||
/**
|
||||
* Uploads [data] as the new avatar of the room, not of the current user.
|
||||
*
|
||||
* @param mimeType the MIME type of the image, for instance `image/jpeg`.
|
||||
* @param data the raw bytes of the image.
|
||||
*/
|
||||
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
|
||||
/** Removes the avatar of the room. */
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Refreshes [roomNotificationSettingsStateFlow], which moves to a pending state and then to a ready or error state.
|
||||
* The failure is reported through that flow as well as through the returned [Result].
|
||||
*/
|
||||
suspend fun updateRoomNotificationSettings(): Result<Unit>
|
||||
|
||||
/**
|
||||
@@ -83,11 +125,15 @@ interface JoinedRoom : BaseRoom {
|
||||
|
||||
/**
|
||||
* Update the room's visibility in the room directory.
|
||||
*
|
||||
* @param roomVisibility whether the room should be listed publicly in the directory.
|
||||
*/
|
||||
suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result<Unit>
|
||||
|
||||
/**
|
||||
* Update room history visibility for this room.
|
||||
* Update room history visibility for this room, i.e. how much of the past a new member is allowed to read.
|
||||
*
|
||||
* @param historyVisibility the new history visibility to apply.
|
||||
*/
|
||||
suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result<Unit>
|
||||
|
||||
@@ -98,6 +144,8 @@ interface JoinedRoom : BaseRoom {
|
||||
* - `true` if the room alias didn't exist and it's now published.
|
||||
* - `false` if the room alias was already present so it couldn't be
|
||||
* published.
|
||||
*
|
||||
* @param roomAlias the alias to publish.
|
||||
*/
|
||||
suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result<Boolean>
|
||||
|
||||
@@ -108,6 +156,8 @@ interface JoinedRoom : BaseRoom {
|
||||
* - `true` if the room alias was present and it's now removed from the
|
||||
* room directory.
|
||||
* - `false` if the room alias didn't exist so it couldn't be removed.
|
||||
*
|
||||
* @param roomAlias the alias to remove.
|
||||
*/
|
||||
suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result<Boolean>
|
||||
|
||||
@@ -117,26 +167,74 @@ interface JoinedRoom : BaseRoom {
|
||||
suspend fun enableEncryption(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Update the join rule for this room.
|
||||
* Update the join rule for this room, i.e. who is allowed to join it.
|
||||
*
|
||||
* @param joinRule the new join rule to apply.
|
||||
*/
|
||||
suspend fun updateJoinRule(joinRule: JoinRule): Result<Unit>
|
||||
|
||||
/**
|
||||
* Changes the power level of individual members, which is how roles such as moderator or administrator are granted.
|
||||
*
|
||||
* @param changes the new power level to apply to each user.
|
||||
*/
|
||||
suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit>
|
||||
|
||||
/**
|
||||
* Changes the power level required for each action in the room, such as inviting, kicking or changing the room name.
|
||||
*
|
||||
* @param roomPowerLevelsValues the new threshold for every action.
|
||||
*/
|
||||
suspend fun updatePowerLevels(roomPowerLevelsValues: RoomPowerLevelsValues): Result<Unit>
|
||||
|
||||
/** Restores the power levels required for each action to the defaults the room was created with. */
|
||||
suspend fun resetPowerLevels(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Changes the name of the room.
|
||||
*
|
||||
* @param name the new room name.
|
||||
*/
|
||||
suspend fun setName(name: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Changes the topic of the room.
|
||||
*
|
||||
* @param topic the new room topic.
|
||||
*/
|
||||
suspend fun setTopic(topic: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Reports an event to the homeserver moderators, optionally ignoring its sender at the same time.
|
||||
*
|
||||
* @param eventId the event being reported.
|
||||
* @param reason the explanation sent to the moderators.
|
||||
* @param blockUserId the sender to also add to the ignore list, or `null` to only report the event.
|
||||
*/
|
||||
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
|
||||
|
||||
/**
|
||||
* Removes a member from the room; they are able to join again unless they are also banned.
|
||||
*
|
||||
* @param userId the member to remove.
|
||||
* @param reason the reason recorded in the membership event, or `null` for none.
|
||||
*/
|
||||
suspend fun kickUser(userId: UserId, reason: String? = null): Result<Unit>
|
||||
|
||||
/**
|
||||
* Bans a user from the room, removing them if they are a member and preventing them from joining again.
|
||||
*
|
||||
* @param userId the user to ban.
|
||||
* @param reason the reason recorded in the membership event, or `null` for none.
|
||||
*/
|
||||
suspend fun banUser(userId: UserId, reason: String? = null): Result<Unit>
|
||||
|
||||
/**
|
||||
* Lifts the ban on a user, allowing them to join the room again; it does not invite them back.
|
||||
*
|
||||
* @param userId the user to unban.
|
||||
* @param reason the reason recorded in the membership event, or `null` for none.
|
||||
*/
|
||||
suspend fun unbanUser(userId: UserId, reason: String? = null): Result<Unit>
|
||||
|
||||
/**
|
||||
@@ -161,6 +259,12 @@ interface JoinedRoom : BaseRoom {
|
||||
*/
|
||||
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
|
||||
|
||||
/**
|
||||
* Enables or disables the send queue of this room only; see [io.element.android.libraries.matrix.api.MatrixClient.setAllSendQueuesEnabled]
|
||||
* for the session-wide equivalent and for why a queue disables itself.
|
||||
*
|
||||
* @param enabled whether the send queue of this room should be enabled.
|
||||
*/
|
||||
suspend fun setSendQueueEnabled(enabled: Boolean)
|
||||
|
||||
/**
|
||||
|
||||
+6
@@ -12,7 +12,13 @@ import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
|
||||
|
||||
/** A reference to a room either invited, knocked or banned. */
|
||||
interface NotJoinedRoom : AutoCloseable {
|
||||
/** What is publicly known about the room: name, topic, avatar, join rule and member count. */
|
||||
val previewInfo: RoomPreviewInfo
|
||||
|
||||
/**
|
||||
* The local room, available when the user has a membership in it, for instance after being invited.
|
||||
* It is `null` for a room only discovered through a preview, and closing this instance also closes it.
|
||||
*/
|
||||
val localRoom: BaseRoom?
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
|
||||
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
+14
@@ -10,7 +10,21 @@ package io.element.android.libraries.matrix.api.room.alias
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
|
||||
/**
|
||||
* Helpers to build and validate room aliases, delegating to the SDK so that the app and the server agree on what is acceptable.
|
||||
*/
|
||||
interface RoomAliasHelper {
|
||||
/**
|
||||
* Turns a room display name into a usable alias local part, dropping the characters that are not allowed in an alias.
|
||||
*
|
||||
* @param name the room display name to derive the alias from.
|
||||
*/
|
||||
fun roomAliasNameFromRoomDisplayName(name: String): String
|
||||
|
||||
/**
|
||||
* Whether the given alias is well-formed; this is a format check only and says nothing about the alias being taken.
|
||||
*
|
||||
* @param roomAlias the alias to validate.
|
||||
*/
|
||||
fun isRoomAliasValid(roomAlias: RoomAlias): Boolean
|
||||
}
|
||||
|
||||
+11
@@ -11,7 +11,18 @@ package io.element.android.libraries.matrix.api.room.join
|
||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
|
||||
/**
|
||||
* Joins a room and reports the join to analytics, so that callers do not have to do both.
|
||||
*/
|
||||
interface JoinRoom {
|
||||
/**
|
||||
* Joins the room, picking the right SDK call depending on whether an id or an alias was given, and captures an analytics event on success.
|
||||
* A `Forbidden` answer from the server is translated into [Failures.UnauthorizedJoin] so callers can tell it apart from other errors.
|
||||
*
|
||||
* @param roomIdOrAlias the id or the alias of the room to join.
|
||||
* @param serverNames servers to ask about the room, ignored when joining by alias.
|
||||
* @param trigger what made the user join, recorded in the analytics event.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
roomIdOrAlias: RoomIdOrAlias,
|
||||
serverNames: List<String>,
|
||||
|
||||
+30
@@ -11,20 +11,50 @@ package io.element.android.libraries.matrix.api.room.knock
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* A pending request from a user to join a room whose join rule is knock, along with the actions a moderator can take on it.
|
||||
*
|
||||
* Instances are snapshots handed out by [io.element.android.libraries.matrix.api.room.JoinedRoom.knockRequestsFlow].
|
||||
*/
|
||||
interface KnockRequest {
|
||||
/** The id of the membership event carrying the request. */
|
||||
val eventId: EventId
|
||||
|
||||
/** The user asking to join. */
|
||||
val userId: UserId
|
||||
|
||||
/** The display name of the user asking to join, or `null` if they have none. */
|
||||
val displayName: String?
|
||||
|
||||
/** The avatar of the user asking to join, or `null` if they have none. */
|
||||
val avatarUrl: String?
|
||||
|
||||
/** The message the user attached to their request, or `null` if they sent none. */
|
||||
val reason: String?
|
||||
|
||||
/** When the request was made, in milliseconds since the epoch, or `null` if the server did not provide it. */
|
||||
val timestamp: Long?
|
||||
|
||||
/** Whether a moderator has already marked this request as seen, so that it is no longer highlighted as new. */
|
||||
val isSeen: Boolean
|
||||
|
||||
/** Accepts the request, which makes the user a member of the room. */
|
||||
suspend fun accept(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Declines the request; the user is free to knock again.
|
||||
*
|
||||
* @param reason the explanation recorded in the membership event, or `null` for none.
|
||||
*/
|
||||
suspend fun decline(reason: String?): Result<Unit>
|
||||
|
||||
/**
|
||||
* Declines the request and bans the user, so that they cannot knock again.
|
||||
*
|
||||
* @param reason the explanation recorded in the membership event, or `null` for none.
|
||||
*/
|
||||
suspend fun declineAndBan(reason: String?): Result<Unit>
|
||||
|
||||
/** Marks the request as seen, without accepting or declining it. */
|
||||
suspend fun markAsSeen(): Result<Unit>
|
||||
}
|
||||
|
||||
+24
@@ -59,12 +59,16 @@ interface RoomPermissions : AutoCloseable {
|
||||
/**
|
||||
* Returns true if the current user is able to send a specific message type
|
||||
* in the room.
|
||||
*
|
||||
* @param message the kind of message the caller wants to send.
|
||||
*/
|
||||
fun canOwnUserSendMessage(message: MessageEventType): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the current user is able to send a specific state event
|
||||
* type in the room.
|
||||
*
|
||||
* @param stateEvent the kind of state event the caller wants to send.
|
||||
*/
|
||||
fun canOwnUserSendState(stateEvent: StateEventType): Boolean
|
||||
|
||||
@@ -77,48 +81,66 @@ interface RoomPermissions : AutoCloseable {
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to ban in the
|
||||
* room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserBan(userId: UserId): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to invite in the
|
||||
* room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserInvite(userId: UserId): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to kick in the
|
||||
* room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserKick(userId: UserId): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to pin or unpin
|
||||
* events in the room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserPinUnpin(userId: UserId): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to redact
|
||||
* messages of other users in the room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserRedactOther(userId: UserId): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to redact
|
||||
* their own messages in the room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserRedactOwn(userId: UserId): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to send a
|
||||
* specific message type in the room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
* @param message the kind of message to check.
|
||||
*/
|
||||
fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if the user with the given userId is able to send a
|
||||
* specific state event type in the room.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
* @param stateEvent the kind of state event to check.
|
||||
*/
|
||||
fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean
|
||||
|
||||
@@ -127,6 +149,8 @@ interface RoomPermissions : AutoCloseable {
|
||||
* notification in the room.
|
||||
*
|
||||
* The call may fail if there is an error in getting the power levels.
|
||||
*
|
||||
* @param userId the member whose ability is checked.
|
||||
*/
|
||||
fun canUserTriggerRoomNotification(userId: UserId): Boolean
|
||||
}
|
||||
|
||||
+17
@@ -9,10 +9,27 @@ package io.element.android.libraries.matrix.api.room.threads
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Exposes the paginated list of threads of a single room, kept up to date by the SDK.
|
||||
*
|
||||
* The list grows through explicit [paginate] calls; [destroy] must be called to release the underlying SDK resources.
|
||||
*/
|
||||
interface ThreadsListService {
|
||||
/**
|
||||
* The threads loaded so far, starting empty and re-emitted in full whenever the SDK sends an update.
|
||||
* The underlying subscription is started on the first call and shared by later ones.
|
||||
*/
|
||||
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
|
||||
|
||||
/** The pagination state of the list, emitting the current value immediately so callers know whether more threads can be loaded. */
|
||||
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
|
||||
|
||||
/** Loads the next page of threads, which will be reflected in [subscribeToItemUpdates]. */
|
||||
suspend fun paginate(): Result<Unit>
|
||||
|
||||
/** Discards the loaded threads and starts the list again from the most recent one. */
|
||||
suspend fun reset(): Result<Unit>
|
||||
|
||||
/** Releases the SDK resources and stops the item subscription; the service is unusable afterwards. */
|
||||
fun destroy()
|
||||
}
|
||||
|
||||
+9
@@ -10,12 +10,21 @@ package io.element.android.libraries.matrix.api.roomdirectory
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* A single, stateful search in the public room directory: one query at a time, with paginated results.
|
||||
*
|
||||
* Obtain an instance from [RoomDirectoryService.createRoomDirectoryList].
|
||||
*/
|
||||
interface RoomDirectoryList {
|
||||
/**
|
||||
* Starts a filtered search for the server.
|
||||
* If the filter is not provided it will search for all the rooms. You can specify a batch_size to control the number of rooms to fetch per request.
|
||||
* If the via_server is not provided it will search in the current homeserver by default.
|
||||
* This method will clear the current search results and start a new one
|
||||
*
|
||||
* @param filter the text to look for, or `null` to list every room.
|
||||
* @param batchSize the number of rooms to fetch per request.
|
||||
* @param viaServerName the server whose directory to search, or `null` to use the user's own homeserver.
|
||||
*/
|
||||
suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result<Unit>
|
||||
|
||||
|
||||
+8
@@ -10,6 +10,14 @@ package io.element.android.libraries.matrix.api.roomdirectory
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* Gives access to the public room directory, i.e. the rooms a server advertises to users who have not joined them.
|
||||
*/
|
||||
interface RoomDirectoryService {
|
||||
/**
|
||||
* Creates an independent directory search, which is stateful and holds one query at a time.
|
||||
*
|
||||
* @param scope the lifetime of the search; the underlying SDK resources are released when it completes, so there is nothing to close by hand.
|
||||
*/
|
||||
fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList
|
||||
}
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ package io.element.android.libraries.matrix.api.roomlist
|
||||
* It lets load rooms on demand and filter them.
|
||||
*/
|
||||
interface DynamicRoomList : RoomList {
|
||||
/** The number of rooms added to the list by each [loadMore] call, as chosen when the list was created. */
|
||||
val pageSize: Int
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user