Merge branch 'develop' into misc/reactions-pinned-messages

This commit is contained in:
Benoit Marty
2026-08-13 09:32:25 +02:00
committed by GitHub
2312 changed files with 23412 additions and 8843 deletions
@@ -39,7 +39,7 @@ fun AnalyticsPreferencesView(
)
Column(modifier) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_analytics_settings_share_data))
},
supportingContent = {
@@ -14,6 +14,7 @@
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -22,7 +22,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
@@ -84,7 +83,7 @@ class CallScreenPresenter(
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag()
val theme = if (ElementTheme.isLightTheme) "light" else "dark"
val theme = "dark"
DisposableEffect(Unit) {
coroutineScope.launch {
@@ -38,6 +38,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
@@ -143,24 +144,28 @@ class ElementCallActivity :
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive) {
if (state.isCallActive) {
setCallIsActive()
ForcedDarkElementTheme(
colors = colors,
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive) {
if (state.isCallActive) {
setCallIsActive()
}
}
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {
consoleMessageLogger.log("ElementCall", it)
},
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
}
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {
consoleMessageLogger.log("ElementCall", it)
},
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
}
}
}
@@ -0,0 +1,23 @@
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.api"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,22 @@
/*
* 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.features.contentscanner.api
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
/**
* Service to perform security scans in the contents for a given media source.
*
* The default implementation (FOSS) always returns a valid state.
* This will only process media when a [ContentScanner] is provided when building the client in Pro.
*/
fun interface ContentScannerService {
fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
}
@@ -0,0 +1,36 @@
/*
* 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.
*/
import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.impl"
}
setupDependencyInjection()
dependencies {
api(projects.features.contentscanner.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}
@@ -0,0 +1,63 @@
/*
* 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.features.contentscanner.impl
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.IOException
/**
* Default implementation of [ContentScannerService] that uses a [ContentScanner] to scan media sources.
*/
class DefaultContentScannerService(
private val contentScanner: ContentScanner,
private val coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
) : ContentScannerService {
private val dispatcher = coroutineDispatchers.io.limitedParallelism(4)
override fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
val currentState = contentValidationState.getCurrentMediaState(url)
if (currentState != ContentValidationValue.Unknown) continue
contentValidationState.update(url, ContentValidationValue.Loading)
coroutineScope.launch(dispatcher) {
contentScanner.scan(mediaSource)
.onSuccess { isValid ->
val contentValidationValue = if (isValid) ContentValidationValue.Valid else ContentValidationValue.Invalid
contentValidationState.update(url, contentValidationValue)
}
.onFailure { exception ->
when (exception) {
is IOException -> {
// If it's an IO-related exception, we can retry later, so we don't store the error
contentValidationState.update(url, ContentValidationValue.Unknown)
}
is ClientException.ContentScanner if exception.reason.isDangerous() -> {
// It's a dangerous content (banned mime-type?), we mark it as invalid
contentValidationState.update(url, ContentValidationValue.Invalid)
}
else -> {
// For other exceptions, we cache the failure
contentValidationState.update(url, ContentValidationValue.UnrecoverableError(exception))
}
}
}
}
}
}
}
@@ -0,0 +1,39 @@
/*
* 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.features.contentscanner.impl
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.EventContentValidationCache
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class DefaultEventContentValidationCache(
private val contentScanner: ContentScanner?,
) : EventContentValidationCache {
private val cache = ConcurrentHashMap<EventId, ContentValidationState>()
override operator fun get(eventId: EventId): ContentValidationState {
return cache.getOrPut(eventId) {
if (contentScanner != null) {
DefaultContentValidationState()
} else {
Timber.v("Content scanner is not available, returning NoopContentValidationState for eventId: $eventId")
NoopContentValidationState()
}
}
}
}
@@ -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.features.contentscanner.impl.di
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
/**
* Noop implementation of [ContentScannerService] that always returns success.
*
* This is used when the content scanner feature is not enabled or available.
*/
class AlwaysValidContentScannerService : ContentScannerService {
override fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState) {
// Always return success for the noop implementation
for (mediaSource in mediaSources) {
val url = mediaSource.safeUrl
contentValidationState.update(url, ContentValidationValue.Valid)
}
}
}
@@ -0,0 +1,42 @@
/*
* 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.features.contentscanner.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
object ContentScannerModule {
@Provides
@SingleIn(RoomScope::class)
fun providesContentScannerService(
contentScanner: ContentScanner?,
@RoomCoroutineScope coroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
): ContentScannerService {
return if (contentScanner != null) {
DefaultContentScannerService(
contentScanner = contentScanner,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
} else {
AlwaysValidContentScannerService()
}
}
}
@@ -0,0 +1,208 @@
/*
* 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.features.contentscanner.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.FileNotFoundException
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultContentScannerServiceTest {
@Test
fun `scan with no media sources returns unknown`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(true) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
mediaSources = emptyList(),
contentValidationState = contentValidationState,
)
scanLambda.assertions().isNeverCalled()
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
}
@Test
fun `scan with single media source`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan with several media sources`() = runTest {
val mediaSourceA = aMediaSource("https://example.com/mediaA.jpg")
val mediaSourceB = aMediaSource("https://example.com/mediaB.jpg")
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { source ->
when (source) {
mediaSourceA -> Result.success(true)
mediaSourceB -> Result.success(false)
else -> error("Unexpected media source: $source")
}
}
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
createDefaultContentScannerService(
contentScanner = scanner,
).scan(
mediaSources = listOf(mediaSourceA, mediaSourceB),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledExactly(2)
assertThat(contentValidationState.getMediaStateFlow(mediaSourceA.safeUrl).first()).isEqualTo(ContentValidationValue.Valid)
assertThat(contentValidationState.getMediaStateFlow(mediaSourceB.safeUrl).first()).isEqualTo(ContentValidationValue.Invalid)
assertThat(contentValidationState.overallStateFlow.first()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan won't be repeated for sources which are already being scanned`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
// First scan attempt
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
// Immediately after, a new scan attempt is made for the same media source
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
// The scan action was done just once, and the overall state is invalid as expected
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Invalid)
}
@Test
fun `scan won't be repeated for sources which already have a cached value`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.success(false) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = NoopContentValidationState(ContentValidationValue.Valid)
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isNeverCalled()
}
@Test
fun `if a scan fails with a recoverable error, the content validation value will be back to Unknown`() = runTest {
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.failure(FileNotFoundException("Some IO exception")) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.Unknown)
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.Unknown)
}
@Test
fun `if a scan fails with an unrecoverable error, the content validation value will be back to UnrecoverableError`() = runTest {
val error = OutOfMemoryError("BOOM")
val scanLambda = lambdaRecorder<MediaSource, Result<Boolean>> { Result.failure(error) }
val scanner = FakeContentScanner(scan = scanLambda)
val contentValidationState = DefaultContentValidationState()
val mediaSource = aMediaSource("https://example.com/media.jpg")
val service = createDefaultContentScannerService(
contentScanner = scanner,
)
service.scan(
mediaSources = listOf(mediaSource),
contentValidationState = contentValidationState,
)
runCurrent()
scanLambda.assertions().isCalledOnce().with(value(mediaSource))
assertThat(contentValidationState.getCurrentMediaState(mediaSource.safeUrl)).isEqualTo(ContentValidationValue.UnrecoverableError(error))
assertThat(contentValidationState.getCurrentOverallState()).isEqualTo(ContentValidationValue.UnrecoverableError(error))
}
private fun TestScope.createDefaultContentScannerService(
contentScanner: ContentScanner = FakeContentScanner(),
sessionCoroutineScope: CoroutineScope = backgroundScope,
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): DefaultContentScannerService {
return DefaultContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
}
@@ -0,0 +1,44 @@
/*
* 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.features.contentscanner.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.matrix.ui.media.contentvalidation.DefaultContentValidationState
import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState
import org.junit.Test
class DefaultEventContentValidationCacheTest {
@Test
fun `get returns NoopContentValidationState when ContentScanner is null`() {
// Given
val contentScanner: ContentScanner? = null
val cache = DefaultEventContentValidationCache(contentScanner)
// When
val state = cache[AN_EVENT_ID]
// Then
assertThat(state).isInstanceOf(NoopContentValidationState::class.java)
}
@Test
fun `get returns an actual DefaultContentValidationState when ContentScanner is not null`() {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val cache = DefaultEventContentValidationCache(contentScanner)
// When
val state = cache[AN_EVENT_ID]
// Then
assertThat(state).isInstanceOf(DefaultContentValidationState::class.java)
}
}
@@ -0,0 +1,54 @@
/*
* 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.features.contentscanner.impl.di
import com.google.common.truth.Truth.assertThat
import io.element.android.features.contentscanner.impl.DefaultContentScannerService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ContentScannerModuleTest {
@Test
fun `ContentScannerModule provides NoopContentScannerService when ContentScanner is null`() = runTest {
// Given
val contentScanner: ContentScanner? = null
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
// Then
assertThat(contentScannerService).isInstanceOf(AlwaysValidContentScannerService::class.java)
}
@Test
fun `ContentScannerModule provides DefaultContentScannerService when ContentScanner is not null`() = runTest {
// Given
val contentScanner: ContentScanner? = FakeContentScanner()
val sessionCoroutineScope = backgroundScope
val coroutineDispatchers = testCoroutineDispatchers()
// When
val contentScannerService = ContentScannerModule.providesContentScannerService(
contentScanner = contentScanner,
coroutineScope = sessionCoroutineScope,
coroutineDispatchers = coroutineDispatchers,
)
// Then
assertThat(contentScannerService).isInstanceOf(DefaultContentScannerService::class.java)
}
}
@@ -43,6 +43,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.invitepeople.api)
implementation(projects.features.enterprise.api)
api(projects.features.createroom.api)
testCommonDependencies(libs, true)
@@ -53,4 +54,5 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.enterprise.test)
}
@@ -24,6 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -73,6 +74,7 @@ class ConfigureRoomPresenter(
private val featureFlagService: FeatureFlagService,
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val sessionEnterpriseService: SessionEnterpriseService,
) : Presenter<ConfigureRoomState> {
@AssistedFactory
interface Factory {
@@ -256,7 +258,7 @@ class ConfigureRoomPresenter(
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = true,
isEncrypted = !sessionEnterpriseService.isEncryptionDisabledByHomeserver(),
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
@@ -312,7 +312,7 @@ private fun RoomJoinRuleOptions(
backgroundTint = Color.Transparent,
)
},
headlineContent = {
content = {
val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
@@ -58,7 +58,7 @@ internal fun SelectParentSpaceOptions(
modifier = modifier
) {
ListItem(
headlineContent = {
content = {
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
@@ -126,7 +126,7 @@ private fun SelectParentSpaceBottomSheet(
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
maxLines = 1
@@ -141,7 +141,7 @@ private fun SelectParentSpaceBottomSheet(
for (space in spaces) {
item {
ListItem(
headlineContent = {
content = {
Text(
text = space.displayName,
maxLines = 1
@@ -3,14 +3,34 @@
<string name="screen_create_room_action_create_room">"Ruangan baru"</string>
<string name="screen_create_room_add_people_title">"Undang orang-orang"</string>
<string name="screen_create_room_error_creating_room">"Terjadi kesalahan saat membuat ruangan"</string>
<string name="screen_create_room_error_creating_space">"Ruang tersebut tidak dapat dibuat karena kesalahan yang tidak diketahui. Coba lagi nanti."</string>
<string name="screen_create_room_name_placeholder">"Tambahkan nama…"</string>
<string name="screen_create_room_new_room_title">"Ruangan baru"</string>
<string name="screen_create_room_new_space_title">"Ruang baru"</string>
<string name="screen_create_room_private_option_description">"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."</string>
<string name="screen_create_room_private_option_title">"Pribadi"</string>
<string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string>
<string name="screen_create_room_public_option_short_description">"Siapa pun bisa bergabung."</string>
<string name="screen_create_room_public_option_title">"Umum"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Siapa pun dapat meminta untuk bergabung dengan ijin administrator atau moderator"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Minta untuk bergabung"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Siapa pun di %1$s dapat bergabung, tetapi semua orang lain harus meminta akses."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Ajukan permohonan untuk bergabung"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Pribadi"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Siapa pun dapat bergabung dengan ruangan ini"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Umum"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Siapa pun di %1$s dapat bergabung."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standar"</string>
<string name="screen_create_room_room_access_section_title">"Yang memiliki akses"</string>
<string name="screen_create_room_room_address_section_footer">"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."</string>
<string name="screen_create_room_room_address_section_title">"Alamat ruangan"</string>
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
<string name="screen_create_room_space_selection_no_space_description">"(ruang sudah penuh)"</string>
<string name="screen_create_room_space_selection_no_space_option">"Jangan menambahkan sesuatu ke ruang yang sudah ada."</string>
<string name="screen_create_room_space_selection_no_space_title">"Tidak ada ruang yang dipilih"</string>
<string name="screen_create_room_space_selection_sheet_title">"Tambahkan ke ruang"</string>
<string name="screen_create_room_topic_label">"Topik (opsional)"</string>
<string name="screen_create_room_topic_placeholder">"Tambahkan deskripsi…"</string>
</resources>
@@ -23,7 +23,7 @@
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s にいる全員が参加することができます。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"スタンダード"</string>
<string name="screen_create_room_room_access_section_title">"参加できるユーザー"</string>
<string name="screen_create_room_room_access_section_title">"参加可能なユーザー"</string>
<string name="screen_create_room_room_address_section_footer">"公開ディレクトリで自分を見つけられるようにするには、アドレスが必要です。"</string>
<string name="screen_create_room_room_address_section_title">"アドレス"</string>
<string name="screen_create_room_room_visibility_section_title">"ルームの公開度"</string>
@@ -20,11 +20,13 @@ import io.element.android.features.createroom.impl.configureroom.CreateRoomConfi
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
@@ -56,6 +58,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.matching
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.mockk.mockk
@@ -517,6 +520,30 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest {
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
val matrixClient = createMatrixClient().apply {
createRoomResult = createRoomLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient,
sessionEnterpriseService = sessionEnterpriseService,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private))
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction.isLoading()).isTrue()
assertThat(awaitItem().createRoomAction.isSuccess()).isTrue()
createRoomLambda.assertions().isCalledOnce().with(matching<CreateRoomParameters> { !it.isEncrypted })
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
@@ -552,6 +579,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }),
) = ConfigureRoomPresenter(
isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
@@ -566,5 +594,6 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
sessionEnterpriseService = sessionEnterpriseService,
)
}
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekreft at du vil deaktivere kontoen din. Denne handlingen kan ikke angres."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekreft at du vil slette kontoen din. Denne handlingen kan ikke angres."</string>
<string name="screen_deactivate_account_delete_all_messages">"Slett alle meldingene mine"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Advarsel: Fremtidige brukere vil kunne se ufullstendige samtaler."</string>
<string name="screen_deactivate_account_description">"Deaktivering av kontoen din er %1$s , det vil:"</string>
<string name="screen_deactivate_account_description">"Hvis %1$s sletter kontoen din, vil følgende skje:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s kontoen din (du kan ikke logge på igjen, og ID-en din kan ikke brukes på nytt)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Deaktiver permanent"</string>
<string name="screen_deactivate_account_list_item_2">"Fjern deg fra alle chatterom."</string>
<string name="screen_deactivate_account_list_item_3">"Slett kontoinformasjonen din fra vår identitetsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Meldingene dine vil fortsatt være synlige for registrerte brukere, men vil ikke være tilgjengelige for nye eller uregistrerte brukere hvis du velger å slette dem."</string>
<string name="screen_deactivate_account_title">"Deaktiver kontoen"</string>
<string name="screen_deactivate_account_title">"Slett kontoen"</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Iltimos, hisobingizni o‘chirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib bo‘lmaydi."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Iltimos, hisobingizni oʻchirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib boʻlmaydi."</string>
<string name="screen_deactivate_account_delete_all_messages">"Barcha xabarlarimni o‘chirib tashlang"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Ogohlantirish: Kelgusi foydalanuvchilar chala suhbatlarni ko‘rishi mumkin."</string>
<string name="screen_deactivate_account_description">"Hisobingiz %1$s faolsizlantirilmoqda, u quyidagilarni bajaradi:"</string>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
<string name="screen_deactivate_account_title">"停用帳號"</string>
<string name="screen_deactivate_account_title">"刪除帳號"</string>
</resources>
+1
View File
@@ -17,4 +17,5 @@ dependencies {
implementation(projects.libraries.compound)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
}
@@ -10,13 +10,15 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
@@ -41,6 +43,16 @@ interface EnterpriseService {
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
/**
* Gets the overridden Element Well-Known data if it has been set, or null if not set.
*/
fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
@@ -12,5 +12,7 @@ interface SessionEnterpriseService {
suspend fun isElementCallAvailable(): Boolean
suspend fun tweakMasUrl(url: String): String
suspend fun isEncryptionDisabledByHomeserver(): Boolean
suspend fun init()
}
@@ -0,0 +1,17 @@
/*
* 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.features.enterprise.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -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.features.enterprise.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -23,6 +23,7 @@ dependencies {
api(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
@@ -14,7 +14,9 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -23,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
@@ -45,4 +47,8 @@ class DefaultEnterpriseService : EnterpriseService {
}
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
}
@@ -17,4 +17,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun init() = Unit
override suspend fun tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true
override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false
}
@@ -0,0 +1,30 @@
/*
* 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.features.enterprise.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -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.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
}
@@ -18,5 +18,6 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(projects.tests.testutils)
}
@@ -12,7 +12,9 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@@ -30,7 +32,9 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -39,8 +43,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
tweakMasUrlResult(url, homeserver)
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver, urlContentFetcher)
}
override fun defaultHomeserverList(): List<String> {
@@ -79,4 +83,12 @@ class FakeEnterpriseService(
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
return getNoisyNotificationChannelIdResult(sessionId)
}
override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult()
}
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
}
@@ -15,6 +15,7 @@ import io.element.android.tests.testutils.simulateLongTask
class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val tweakMasUrlResult: (String) -> String = { lambdaError() },
private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
@@ -26,4 +27,8 @@ class FakeSessionEnterpriseService(
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}
override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
return isEncryptionDisabledResult()
}
}
@@ -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.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -0,0 +1,17 @@
/*
* 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.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"نمی‌توانید تأیید کنید؟"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"ایجاد کلید بازیابی جدید"</string>
<string name="screen_identity_confirmation_subtitle">"تأیید این افزاره برای برپایی پیام‌رسانی امن."</string>
<string name="screen_identity_confirmation_title">"تأیید هویتتان"</string>
<string name="screen_identity_confirmation_subtitle">"گزینش چگونگی تأیید برای برپایی پیام‌رسانی امن."</string>
<string name="screen_identity_confirmation_title">"تأیید هویت دیجیتالیتان"</string>
<string name="screen_identity_confirmation_use_another_device">"استفاده از افزاره‌ای دیگر"</string>
<string name="screen_identity_confirmation_use_recovery_key">"استفاده از کلید بازیابی"</string>
<string name="screen_identity_confirmed_subtitle">"اکنون می‌توانید پیام‌ها را به صورت امن فرستاده و بگیرید و هرکسی که با او گپ می‌زنید نیز می‌تواند به این افزاره اعتماد کند."</string>
@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan du ikke bekrefte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opprett en ny gjenopprettingsnøkkel"</string>
<string name="screen_identity_confirmation_subtitle">"Verifiser denne enheten for å sette opp sikker meldingsutveksling."</string>
<string name="screen_identity_confirmation_subtitle">"Velg hvordan du vil verifisere deg for å sette opp sikker meldingsutveksling."</string>
<string name="screen_identity_confirmation_title">"Bekreft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Bruk en annen enhet"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Bruk gjenopprettingsnøkkel"</string>
@@ -14,11 +14,15 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -65,6 +69,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalFloa
import io.element.android.libraries.designsystem.theme.components.HorizontalFloatingToolbarSeparator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
@@ -201,6 +207,9 @@ private fun HomeScaffold(
floatingActionButton = {
val coroutineScope = rememberCoroutineScope()
HomeBottomBar(
// The Scaffold uses top-only insets so the scrollable content can go edge-to-edge behind the
// navigation bar, so the floating toolbar has to apply the bottom inset itself to avoid overlapping it.
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
currentHomeNavigationBarItem = state.currentHomeNavigationBarItem,
onItemClick = { item ->
// scroll to top if selecting the same item
@@ -234,7 +243,15 @@ private fun HomeScaffold(
)
},
floatingActionButtonPosition = FabPosition.Center,
contentWindowInsets = scaffoldScrollableContentInsets,
content = { padding ->
val outerPadding = PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
// Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed
bottom = padding.calculateBottomPadding(),
top = padding.calculateTopPadding()
)
val contentPadding = PaddingValues(
bottom = 96.dp,
)
@@ -251,18 +268,10 @@ private fun HomeScaffold(
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = ::onRoomClick,
onCreateRoomClick = onStartChatClick,
contentPadding = contentPadding,
contentPadding = lazyColumnContentPadding + contentPadding,
modifier = Modifier
.padding(
PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
// Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed
bottom = padding.calculateBottomPadding(),
top = padding.calculateTopPadding()
)
)
.consumeWindowInsets(padding)
.padding(outerPadding)
.consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState)
)
SpaceFiltersView(roomListState.spaceFiltersState)
@@ -271,10 +280,10 @@ private fun HomeScaffold(
HomeSpacesView(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.padding(outerPadding)
.consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState),
contentPadding = contentPadding,
contentPadding = lazyColumnContentPadding + contentPadding,
state = state.homeSpacesState,
lazyListState = spacesLazyListState,
onSpaceClick = { spaceId ->
@@ -8,12 +8,20 @@
package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -21,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -33,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -67,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -96,13 +120,15 @@ fun HomeTopBar(
spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier,
) {
val contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal).asPaddingValues()
Column(modifier) {
TopAppBar(
modifier = Modifier
.backgroundVerticalGradient(
isVisible = !areSearchResultsDisplayed,
)
.statusBarsPadding(),
.statusBarsPadding()
.padding(contentPadding),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
@@ -145,7 +171,7 @@ fun HomeTopBar(
},
// We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton)
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp),
)
@@ -153,7 +179,7 @@ fun HomeTopBar(
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
RoomListFiltersView(
state = filtersState,
modifier = Modifier.padding(bottom = 16.dp)
modifier = Modifier.padding(bottom = 16.dp).padding(contentPadding)
)
}
}
@@ -310,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier,
) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton(
modifier = modifier.then(testTag),
onClick = onClick,
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) {
Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -333,11 +376,69 @@ private fun AccountIcon(
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
@@ -426,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
@@ -63,6 +63,8 @@ import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.components.DisplayNameWithStatus
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.ui.strings.CommonStrings
@@ -126,7 +128,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow(
name = room.name,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
isHighlighted = room.isHighlighted,
dmUserStatus = room.dmUserStatus,
)
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
}
@@ -142,7 +145,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow(
name = room.name,
timestamp = null,
isHighlighted = room.isHighlighted
isHighlighted = room.isHighlighted,
dmUserStatus = null,
)
if (room.canonicalAlias != null) {
Text(
@@ -218,22 +222,21 @@ private fun NameAndTimestampRow(
name: String?,
timestamp: String?,
isHighlighted: Boolean,
dmUserStatus: DisplayedStatus?,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
Text(
modifier = Modifier
.weight(1f)
.clipToBounds(),
val displayName = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name)
DisplayNameWithStatus(
name = displayName,
status = dmUserStatus,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
nameColor = ElementTheme.colors.roomListRoomName,
nameFontStyle = FontStyle.Italic.takeIf { name == null },
)
// Timestamp
Text(
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.dmUserStatus
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.collections.immutable.toImmutableList
@@ -76,6 +77,7 @@ class RoomListRoomSummaryFactory(
}.toImmutableList(),
isTombstoned = roomInfo.successorRoom != null,
isSpace = roomInfo.isSpace,
dmUserStatus = roomInfo.dmUserStatus(),
)
}
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
if (selectedFilters.any { it in filter.incompatibleFilters }) return
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList
@@ -42,6 +43,7 @@ data class RoomListRoomSummary(
val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
val isSpace: Boolean,
val dmUserStatus: DisplayedStatus?,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList
@@ -182,6 +183,7 @@ internal fun aRoomListRoomSummary(
heroes: List<AvatarData> = emptyList(),
isTombstoned: Boolean = false,
isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary(
id = id,
roomId = RoomId(id),
@@ -204,5 +206,6 @@ internal fun aRoomListRoomSummary(
canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned,
isSpace = isSpace
isSpace = isSpace,
dmUserStatus = dmUserStatus,
)
@@ -94,7 +94,7 @@ private fun RoomListModalBottomSheetContent(
.verticalScroll(rememberScrollState())
) {
ListItem(
headlineContent = {
content = {
Text(
text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
@@ -104,7 +104,7 @@ private fun RoomListModalBottomSheetContent(
)
if (contextMenu.hasNewContent) {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
@@ -117,7 +117,7 @@ private fun RoomListModalBottomSheetContent(
)
} else {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
style = MaterialTheme.typography.bodyLarge,
@@ -135,7 +135,7 @@ private fun RoomListModalBottomSheetContent(
CommonStrings.common_favourite to CompoundIcons.Favourite()
}
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = textResId),
style = MaterialTheme.typography.bodyLarge,
@@ -154,7 +154,7 @@ private fun RoomListModalBottomSheetContent(
},
)
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = CommonStrings.common_settings),
style = MaterialTheme.typography.bodyLarge,
@@ -169,7 +169,7 @@ private fun RoomListModalBottomSheetContent(
)
if (canReportRoom) {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_report_room))
},
modifier = Modifier.clickable { onReportRoomClick() },
@@ -182,7 +182,7 @@ private fun RoomListModalBottomSheetContent(
)
}
ListItem(
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_leave_room))
},
modifier = Modifier.clickable { onLeaveRoomClick() },
@@ -13,9 +13,15 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -153,7 +159,8 @@ private fun RoomListSearchContent(
}
},
)
}
},
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
) { padding ->
Column(
modifier = Modifier
@@ -167,6 +174,7 @@ private fun RoomListSearchContent(
LazyColumn(
state = lazyListState,
modifier = Modifier.weight(1f),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(
items = state.results,
@@ -15,9 +15,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -45,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@@ -75,9 +75,7 @@ fun SpaceFiltersView(
}
if (sheetState.isVisible || isSelecting) {
ModalBottomSheet(
modifier = modifier
.systemBarsPadding()
.navigationBarsPadding(),
modifier = modifier,
sheetState = sheetState,
onDismissRequest = {
if (state is SpaceFiltersState.Selecting) {
@@ -85,6 +83,7 @@ fun SpaceFiltersView(
}
},
scrollable = false,
contentWindowInsets = { scaffoldScrollableContentInsets },
) {
Box(
modifier = Modifier
@@ -113,7 +112,7 @@ private fun SpaceFiltersBottomSheetContent(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(vertical = 16.dp)
modifier = modifier.padding(top = 16.dp)
) {
Text(
text = stringResource(R.string.screen_roomlist_your_spaces),
@@ -131,7 +130,9 @@ private fun SpaceFiltersBottomSheetContent(
placeholder = stringResource(CommonStrings.action_search),
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn {
LazyColumn(
contentPadding = lazyColumnContentPadding,
) {
items(filters) { filter ->
SpaceFilterItem(
filter = filter,
@@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"Nonaktifkan pengoptimalan baterai untuk aplikasi ini, untuk memastikan semua notifikasi diterima."</string>
<string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string>
<string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</string>
<string name="banner_new_sound_message">"Pemberitahuan Anda telah diperbarui—lebih jelas, lebih cepat, dan tidak mengganggu."</string>
<string name="banner_new_sound_title">"Kami telah memperbarui suara Anda."</string>
<string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string>
<string name="banner_set_up_recovery_submit">"Siapkan Kunci pemulihan"</string>
<string name="banner_set_up_recovery_title">"Cadangkan obrolan Anda"</string>
@@ -48,6 +50,7 @@ Anda tidak memiliki pesan yang belum dibaca!"</string>
<string name="screen_roomlist_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="screen_roomlist_mark_as_unread">"Tandai sebagai belum dibaca"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ruangan ini telah ditingkatkan"</string>
<string name="screen_roomlist_your_spaces">"Ruang Anda"</string>
<string name="session_verification_banner_message">"Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."</string>
<string name="session_verification_banner_title">"Verifikasi bahwa ini Anda"</string>
</resources>
@@ -5,9 +5,9 @@
<string name="banner_battery_optimization_title_android">"Kommer ikke varslene frem?"</string>
<string name="banner_new_sound_message">"Varslingssignalet ditt er oppdatert – tydeligere, raskere og mindre forstyrrende."</string>
<string name="banner_new_sound_title">"Vi har oppdatert lydene dine"</string>
<string name="banner_set_up_recovery_content">"Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter."</string>
<string name="banner_set_up_recovery_content">"Chattene dine sikkerhetskopieres automatisk med ende-til-ende-kryptering. For å gjenopprette denne sikkerhetskopien og beholde din digitale identitet når du mister tilgang til alle enhetene dine, trenger du gjenopprettingsnøkkelen din."</string>
<string name="banner_set_up_recovery_submit">"Hent gjenopprettingsnøkkel"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gjenoppretting for å beskytte kontoen din"</string>
<string name="banner_set_up_recovery_title">"Sikkerhetskopier chattene dine"</string>
<string name="confirm_recovery_key_banner_message">"Verifiser gjenopprettingsnøkkelen for å opprettholde tilgangen til nøkkellageret og meldingshistorikken."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Skriv inn gjenopprettingsnøkkelen din"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gjenopprettingsnøkkel?"</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Favourites, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.People, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest {
@@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import kotlinx.collections.immutable.toImmutableList
@@ -87,6 +88,7 @@ internal fun createRoomListRoomSummary(
timestamp: String? = null,
isTombstoned: Boolean = false,
isSpace: Boolean = false,
dmUserStatus: DisplayedStatus? = null,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
@@ -109,5 +111,6 @@ internal fun createRoomListRoomSummary(
isDm = false,
heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned,
isSpace = isSpace
isSpace = isSpace,
dmUserStatus = dmUserStatus,
)
@@ -81,7 +81,7 @@ fun DeclineAndBlockView(
) {
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
content = {
Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title))
},
supportingContent = {
@@ -96,7 +96,7 @@ fun DeclineAndBlockView(
Spacer(modifier = Modifier.height(24.dp))
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_report_room))
},
supportingContent = {
@@ -88,7 +88,7 @@ class DefaultInvitePeoplePresenter(
override fun present(): InvitePeopleState {
val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial) }
val queryState = rememberTextFieldState()
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
@@ -332,15 +332,15 @@ class DefaultInvitePeoplePresenter(
showSearchLoader: MutableState<Boolean>,
searchQuery: String,
) = withContext(coroutineDispatchers.io) {
searchResults.value = SearchBarResultState.Initial()
searchResults.value = SearchBarResultState.Initial
showSearchLoader.value = false
val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
userRepository.search(searchQuery).onEach { state ->
showSearchLoader.value = state.isSearching
searchResults.value = when {
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial()
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound()
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Results(state.results.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN
@@ -33,7 +33,7 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
aDefaultInvitePeopleState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query"),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound),
aDefaultInvitePeopleState(
isSearchActive = true,
canInvite = true,
@@ -115,7 +115,7 @@ private fun aDefaultInvitePeopleState(
room: AsyncData<Unit> = AsyncData.Success(Unit),
canInvite: Boolean = false,
searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial(),
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial,
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
@@ -302,13 +302,13 @@ private fun InvitePeopleConfirmModal(
text = stringResource(CommonStrings.action_remove),
onClick = onRemove,
leadingIcon = IconSource.Vector(CompoundIcons.Close()),
modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
modifier = Modifier.weight(1f),
)
Button(
text = stringResource(CommonStrings.action_invite),
onClick = onInvite,
leadingIcon = IconSource.Vector(CompoundIcons.Check()),
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
)
}
}
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Sudah menjadi anggota"</string>
<string name="screen_invite_users_already_invited">"Sudah diundang"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Saat ini Anda belum memiliki obrolan dengan kontak-kontak ini. Konfirmasikan undangan untuk mengundang mereka ke ruang obrolan ini sebelum melanjutkan."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Saat ini Anda belum memiliki obrolan apa pun dengan kontak ini. Konfirmasikan undangan ke ruang obrolan ini sebelum melanjutkan."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Ingin mengundang kontak baru ke ruang ini?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Ingin mengundang kontak baru ke ruang ini?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Allerede medlem"</string>
<string name="screen_invite_users_already_invited">"Allerede invitert"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du har for øyeblikket ingen samtaler med disse kontaktene. Bekreft at du vil invitere dem til dette rommet før du fortsetter."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du har for øyeblikket ingen samtaler med denne kontakten. Bekreft at du vil invitere vedkommende til dette rommet før du fortsetter."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Vil du invitere nye kontakter til dette rommet?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Vil du invitere en ny kontakt til dette rommet?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Allaqachon a\'zo"</string>
<string name="screen_invite_users_already_invited">"Allaqachon taklif qilingan"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Hozirda ushbu kontaktlar bilan hech qanday chatingiz yoʻq. Davom etishdan oldin ularni ushbu xonaga taklif qilishni tasdiqlang."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Hozirda bu kontakt bilan hech qanday chatingiz yoʻq. Davom etishdan oldin ularni ushbu xonaga taklif qilishni tasdiqlang."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Bu xonaga yangi kontaktlar taklif qilinsinmi?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Bu xonaga yangi kontakt taklif qilinsinmi?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"已是成員"</string>
<string name="screen_invite_users_already_invited">"已邀請"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
</resources>
@@ -95,7 +95,7 @@ internal class DefaultInvitePeoplePresenterTest {
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch)
skipItems(2)
skipItems(1)
awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse()
assertThat(it.searchQuery.text.toString()).isEmpty()
@@ -116,7 +116,7 @@ internal class DefaultInvitePeoplePresenterTest {
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true))
skipItems(3)
skipItems(2)
awaitItemAsDefault().also { state ->
assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(state.showSearchLoader).isTrue()
@@ -145,7 +145,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList())
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -198,7 +197,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList())
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -265,7 +263,6 @@ internal class DefaultInvitePeoplePresenterTest {
it
)
})
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -329,7 +326,7 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(2)
skipItems(1)
val resultState = awaitItemAsDefault()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -366,7 +363,6 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(1)
awaitItemAsDefault().also { state ->
// selectedUser is not selected
assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
@@ -732,7 +728,7 @@ internal class DefaultInvitePeoplePresenterTest {
isSearching = true
)
)
skipItems(3)
skipItems(2)
awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -73,6 +74,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@@ -94,7 +97,8 @@ fun KnockRequestsListView(
.padding(padding)
.consumeWindowInsets(padding),
)
}
},
contentWindowInsets = scaffoldScrollableContentInsets,
)
}
@@ -132,7 +136,7 @@ private fun KnockRequestsListContent(
onAcceptClick = ::onAcceptClick,
onDeclineClick = ::onDeclineClick,
onBanClick = ::onBanClick,
contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()),
contentPadding = lazyColumnContentPadding + PaddingValues(bottom = bottomPaddingInPixels.toDp()),
)
}
}
@@ -9,7 +9,7 @@
package io.element.android.features.licenses.impl.details
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,6 +25,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -41,9 +43,10 @@ fun DependenciesDetailsView(
navigationIcon = { BackButton(onClick = onBack) },
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { contentPadding ->
LazyColumn(
modifier = Modifier.padding(contentPadding),
contentPadding = contentPadding + lazyColumnContentPadding,
) {
val licenses = licenseItem.licenses.orEmpty() +
licenseItem.unknownLicenses.orEmpty()
@@ -59,7 +62,7 @@ fun DependenciesDetailsView(
}
}
ListItem(
headlineContent = {
content = {
ClickableLinkText(
text = text,
interactionSource = remember { MutableInteractionSource() },
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,6 +35,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,10 +55,11 @@ fun DependencyLicensesListView(
navigationIcon = { BackButton(onClick = onBackClick) },
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
) { contentPadding ->
Column(
modifier = Modifier
.padding(contentPadding)
.padding(contentPadding + lazyColumnContentPadding)
.padding(horizontal = 16.dp)
) {
if (state.licenses.isSuccess()) {
@@ -72,7 +76,9 @@ fun DependencyLicensesListView(
modifier = Modifier.fillMaxWidth(),
)
}
LazyColumn {
LazyColumn(
contentPadding = lazyColumnContentPadding,
) {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
@@ -94,7 +100,7 @@ fun DependencyLicensesListView(
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
content = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null
private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null
fun createNewHandler() {
private fun resetJobs() {
currentJob?.cancel()
currentJob = null
timerJob?.cancel()
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
}
fun reset() {
currentJob?.cancel()
currentJob = null
resetJobs()
sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
}
}
fun onScannedCode(data: ByteArray) {
currentJob?.cancel()
currentJob = null
resetJobs()
val currentHandler = handler
if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
@Suppress("AssignedValueIsNeverRead")
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
},
onResume = ::onResume,
onDestroy = {
linkMobileHandlerJob?.cancel()
linkDesktopHandlerJob?.cancel()
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
.launchIn(sessionCoroutineScope)
}
private fun onResume() = sessionCoroutineScope.launch {
// Application is resumed, if the step is waiting for auth, send the confirmation
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
it.continuationMessageSender.confirm()
}
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
it.continuationMessageSender.confirm()
}
}
private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen
val error = when (errorType) {
@@ -9,21 +9,25 @@
package io.element.android.features.linknewdevice.impl.screens.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
@@ -34,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.permissions.api.PermissionsView
import kotlinx.collections.immutable.persistentListOf
@@ -62,6 +67,7 @@ fun DesktopNoticeView(
title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
modifier = modifier,
isScrollable = true,
buttons = {
Button(
text = stringResource(R.string.screen_link_new_device_desktop_submit),
@@ -71,11 +77,10 @@ fun DesktopNoticeView(
}
) {
Column(
Modifier.fillMaxWidth()
Modifier.fillMaxWidth(),
) {
Spacer(modifier = Modifier.height(40.dp))
NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
items = persistentListOf(
AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)),
annotatedTextWithBold(
@@ -88,6 +93,17 @@ fun DesktopNoticeView(
AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)),
)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(ElementTheme.colors.bgCriticalSubtle)
.padding(16.dp)
.fillMaxWidth(),
text = stringResource(R.string.screen_link_new_device_desktop_warning),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textCriticalPrimary,
)
}
}
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
/**
* Max number of QrCode rotation.
* When QrCode is rotated this number of time, a TimeOut error is emitted.
* Set to 0 to disable QrCode rotation.
*/
private const val MAX_QR_CODE_ROTATION = 0
@ContributesNode(SessionScope::class)
@AssistedInject
class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data,
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
)
@Composable
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
@Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> {
@AssistedFactory
interface Factory {
fun create(initialData: String): ShowQrCodePresenter
fun create(
initialData: String,
maxQrCodeRotation: Int,
): ShowQrCodePresenter
}
private var loadingJob: Job? = null
@Composable
override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
delay(1000)
delay(1.seconds)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state
}
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
}
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
@@ -13,6 +13,8 @@
<string name="screen_link_new_device_mobile_step2">"گزینش %1$s"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"اکنون چه؟"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"آزمودن ورود دوباره با کد پاس در صورت مشکل شبکه"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"آزمون شبکهٔ وای‌فایی دیگر یا استفاده از دادهٔ همراهتان به جای وای‌فای در صورت مواجه دوباره با مشکل"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"ورود دستی در صورت کار نکردنش"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"اتّصال ناامن"</string>
<string name="screen_qr_code_login_device_code_subtitle">"از شما خواسته خواهد شد که دو رقم نشان داده روی این افزاره را وارد کنید."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
@@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Pindai kode QR"</string>
<string name="screen_link_new_device_desktop_step1">"Buka %1$s di laptop atau komputer desktop."</string>
<string name="screen_link_new_device_desktop_step3">"Pindai kode QR dengan perangkat ini"</string>
<string name="screen_link_new_device_desktop_submit">"Siap untuk memindai"</string>
<string name="screen_link_new_device_desktop_title">"Buka %1$s di komputer desktop untuk mendapatkan kode QR."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Angkanya tidak cocok"</string>
<string name="screen_link_new_device_enter_number_notice">"Masukkan kode 2 digit"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ini akan memverifikasi bahwa koneksi ke perangkat Anda yang lain aman."</string>
<string name="screen_link_new_device_enter_number_title">"Masukkan nomor yang ditampilkan di perangkat Anda yang lain"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Penyedia akun Anda tidak mendukung %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s tidak didukung"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Penyedia akun Anda tidak mendukung masuk ke perangkat baru menggunakan kode QR."</string>
<string name="screen_link_new_device_error_not_supported_title">"Kode QR tidak didukung"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Proses masuk dibatalkan di perangkat lain."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_link_new_device_mobile_step1">"Buka %1$s di perangkat lain."</string>
<string name="screen_link_new_device_mobile_step2">"Pilih %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"Masuk dengan kode QR"</string>
<string name="screen_link_new_device_mobile_step3">"Pindai kode QR yang ditampilkan di sini dengan perangkat lain."</string>
<string name="screen_link_new_device_mobile_title">"Buka %1$s di perangkat lain."</string>
<string name="screen_link_new_device_root_desktop_computer">"Komputer desktop"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Memuat kode QR…"</string>
<string name="screen_link_new_device_root_mobile_device">"Perangkat seluler"</string>
<string name="screen_link_new_device_root_title">"Jenis perangkat apa yang ingin Anda tautkan?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Silakan coba lagi dan pastikan Anda telah memasukkan kode 2 digit dengan benar. Jika angka-angka tersebut masih tidak cocok, hubungi penyedia akun Anda."</string>
<string name="screen_link_new_device_wrong_number_title">"Angkanya tidak cocok"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
@@ -23,6 +40,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Proses masuk ditolak di perangkat lain."</string>
<string name="screen_qr_code_login_error_declined_title">"Proses masuk ditolak"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Anda tidak perlu melakukan hal lain."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Perangkat Anda yang lain sudah masuk"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_qr_code_login_error_expired_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR.
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Zeskanuj kod QR za pomocą tego urządzenia"</string>
<string name="screen_link_new_device_desktop_submit">"Gotowy do skanowania"</string>
<string name="screen_link_new_device_desktop_title">"Otwórz %1$s na komputerze stacjonarnym, aby uzyskać kod QR"</string>
<string name="screen_link_new_device_desktop_warning">"Zeskanuj kod QR dopiero po wykonaniu powyższych kroków na swoim urządzeniu. Nigdy nie skanuj kodu przesłanego przez inną osobę."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Liczby nie pasują do siebie"</string>
<string name="screen_link_new_device_enter_number_notice">"Wprowadź 2-cyfrowy kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Pozwoli to sprawdzić, czy połączenie z drugim urządzeniem jest bezpieczne."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Зіскануйте QR-код цим пристроєм"</string>
<string name="screen_link_new_device_desktop_submit">"Готовий до сканування"</string>
<string name="screen_link_new_device_desktop_title">"Відкрийте %1$s на комп\'ютері, щоб отримати QR-код"</string>
<string name="screen_link_new_device_desktop_warning">"Скануйте QR-код лише після виконання наведених вище кроків на своєму власному пристрої. Ніколи не скануйте код, надісланий іншою особою."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Цифри не збігаються"</string>
<string name="screen_link_new_device_enter_number_notice">"Введіть 2-значний код"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Це підтвердить, що з\'єднання з іншим пристроєм захищене."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此设备扫描二维码"</string>
<string name="screen_link_new_device_desktop_submit">"准备进行扫描"</string>
<string name="screen_link_new_device_desktop_title">"在台式电脑上打开 %1$s 以获取二维码"</string>
<string name="screen_link_new_device_desktop_warning">"确保仅在属于你自己的设备上按上述步骤操作后再扫描二维码。绝对不要扫描来自他人的二维码。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"数字不匹配"</string>
<string name="screen_link_new_device_enter_number_notice">"输入两位数字的代码"</string>
<string name="screen_link_new_device_enter_number_subtitle">"这将验证你与其它设备的连接是否安全。"</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scan the QR code with this device"</string>
<string name="screen_link_new_device_desktop_submit">"Ready to scan"</string>
<string name="screen_link_new_device_desktop_title">"Open %1$s on a desktop computer to get the QR code"</string>
<string name="screen_link_new_device_desktop_warning">"Only scan a QR code after following the steps above on your own device. Never scan a code sent by another person."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"The numbers don’t match"</string>
<string name="screen_link_new_device_enter_number_notice">"Enter 2-digit code"</string>
<string name="screen_link_new_device_enter_number_subtitle">"This will verify that the connection to your other device is secure."</string>
@@ -91,10 +91,36 @@ class ShowQrCodePresenterTest {
}
}
@Test
fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
maxQrCodeRotation = 0,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
expectNoEvents()
}
}
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter(
initialData = "DATA",
maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler,
)
}
@@ -29,14 +29,11 @@ internal class MapTilerTileServerStyleUriBuilder(
customMapStyleUrl: String?,
darkMode: Boolean,
): String {
return buildString {
if (customMapStyleUrl.isNullOrBlank()) {
val mapId = if (darkMode) darkMapId else lightMapId
append("$baseUrl/$mapId/style.json")
} else {
append(customMapStyleUrl)
}
append("?key=$apiKey")
return if (customMapStyleUrl.isNullOrBlank()) {
val mapId = if (darkMode) darkMapId else lightMapId
"$baseUrl/$mapId/style.json?key=$apiKey"
} else {
customMapStyleUrl
}
}
}
@@ -46,7 +46,7 @@ class MapTilerTileServerStyleUriBuilderTest {
customMapStyleUrl = "https://custom.url/style.json",
darkMode = false,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
).isEqualTo("https://custom.url/style.json")
}
@Test
@@ -56,6 +56,36 @@ class MapTilerTileServerStyleUriBuilderTest {
customMapStyleUrl = "https://custom.url/style.json",
darkMode = true,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
).isEqualTo("https://custom.url/style.json")
}
@Test
fun `custom map uri with its own api key is not altered`() {
assertThat(
builder.build(
customMapStyleUrl = "https://api.maptiler.com/maps/streets-v2/style.json?key=anOperatorKey",
darkMode = false,
)
).isEqualTo("https://api.maptiler.com/maps/streets-v2/style.json?key=anOperatorKey")
}
@Test
fun `custom map uri with an existing query is not altered`() {
assertThat(
builder.build(
customMapStyleUrl = "https://self.hosted/style.json?foo=bar",
darkMode = true,
)
).isEqualTo("https://self.hosted/style.json?foo=bar")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
assertThat(
builder.build(
customMapStyleUrl = " ",
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
}
}
@@ -52,9 +52,11 @@ class DefaultActiveLiveLocationShareManager(
private val clock: SystemClock,
private val sessionObserver: SessionObserver,
) : ActiveLiveLocationShareManager, LiveLocationReceiver {
private data class Timeout(val expiresAt: Instant, val job: Job)
private val isSetup = AtomicBoolean(false)
private val cachedRooms = ConcurrentHashMap<RoomId, JoinedRoom>()
private val timeoutJobs = ConcurrentHashMap<RoomId, Job>()
private val timeouts = ConcurrentHashMap<RoomId, Timeout>()
private val syncedActiveShareIds = MutableStateFlow<Set<BeaconId>>(emptySet())
private val localSharingRoomIds = MutableStateFlow<Set<RoomId>>(emptySet())
override val sharingRoomIds: StateFlow<Set<RoomId>> = localSharingRoomIds
@@ -139,9 +141,9 @@ class DefaultActiveLiveLocationShareManager(
}
override suspend fun onLocationUpdate(location: Location) {
val activeSharesCount = localSharingRoomIds.value.size
Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
localSharingRoomIds.value.forEach { roomId ->
val active = stopExpiredShares()
Timber.d("ActiveLiveLocationShareManager received location update for ${active.size} active share(s)")
active.forEach { roomId ->
Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
sendLiveLocation(roomId, location)
.onFailure {
@@ -150,6 +152,19 @@ class DefaultActiveLiveLocationShareManager(
}
}
private suspend fun stopExpiredShares(): List<RoomId> {
val nowMillis = clock.epochMillis()
val (expired, active) = localSharingRoomIds.value.partition { roomId ->
val timeout = timeouts[roomId] ?: return@partition false
timeout.expiresAt.toEpochMilliseconds() <= nowMillis
}
expired.forEach { roomId ->
Timber.d("ActiveLiveLocationShareManager location tick detected expired share for room $roomId, stopping")
stopShare(roomId)
}
return active
}
private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result<Unit> {
val room = cachedRooms.getOrPut(roomId) {
matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
@@ -192,20 +207,21 @@ class DefaultActiveLiveLocationShareManager(
}
private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
timeoutJobs.remove(roomId)?.cancel()
timeouts.remove(roomId)?.job?.cancel()
val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
val job = matrixClient.sessionCoroutineScope.launch {
delay(delayMillis)
stopShare(roomId)
.onFailure { error ->
Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
}
}
timeouts[roomId] = Timeout(expiresAt = expiresAt, job = job)
}
private suspend fun stopLocalShare(roomId: RoomId) {
Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
timeoutJobs.remove(roomId)?.cancel()
timeouts.remove(roomId)?.job?.cancel()
val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
cachedRooms.remove(roomId)?.close()
liveLocationStore.removeLiveLocationExpiry(roomId)
@@ -220,11 +236,11 @@ class DefaultActiveLiveLocationShareManager(
sessionObserver.removeListener(sessionListener)
coordinator.unregister(matrixClient.sessionId)
liveLocationStore.clear()
timeouts.values.forEach { it.job.cancel() }
timeouts.clear()
for (room in cachedRooms.values) {
room.close()
timeoutJobs[room.roomId]?.cancel()
}
timeoutJobs.clear()
cachedRooms.clear()
localSharingRoomIds.value = emptySet()
syncedActiveShareIds.value = emptySet()
@@ -261,7 +261,7 @@ private fun ShareCurrentLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.screen_share_my_location_action))
},
onClick = onClick,
@@ -276,7 +276,7 @@ private fun SharePinLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
onClick = onClick,
@@ -291,7 +291,7 @@ private fun ShareLiveLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.action_share_live_location))
},
onClick = onClick,
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Vaša povijest lokacije uživo bit će pohranjena u sobi i vidljiva članovima nakon završetka sesije."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Odaberite koliko dugo želite dijeliti svoju lokaciju uživo."</string>
<string name="screen_share_location_live_location_missing_permissions">"Nemate dopuštenja za dijeljenje svoje lokacije uživo u ovoj sobi"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Riwayat lokasi langsung Anda akan disimpan di ruang obrolan dan dapat dilihat oleh anggota setelah sesi berakhir."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Pilih berapa lama Anda ingin membagikan lokasi langsung Anda."</string>
<string name="screen_share_location_live_location_missing_permissions">"Anda tidak memiliki izin untuk membagikan lokasi real-time Anda di ruang ini"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Din sanntids lokasjonshistorikk lagres i rommet og vil være synlig for medlemmene etter at økten er avsluttet."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Velg hvor lenge du vil dele din sanntidslokasjon."</string>
<string name="screen_share_location_live_location_missing_permissions">"Du har ikke tillatelse til å dele din sanntidslokasjon i dette rommet"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Jonli joylashuv tarixingiz chat-xonada saqlanadi va sessiya tugaganidan keyin homiylarga ko‘rinadi."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Jonli joylashuvingiz qancha vaqt ulashilishini tanlang."</string>
<string name="screen_share_location_live_location_missing_permissions">"Bu xonada jonli joylashuvingizni ulashish uchun sizda ruxsat yoʻq"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
</resources>
@@ -373,6 +373,53 @@ class DefaultActiveLiveLocationShareManagerTest {
}
}
@Test
fun `location update after expiry stops the share and does not send location`() = runTest {
val liveLocationStore = createInMemoryLiveLocationStore()
val beaconInfoUpdates = MutableSharedFlow<BeaconInfoUpdate>(replay = 1)
val stopLiveLocationShareResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val sendLiveLocationResult = lambdaRecorder<String, Result<Unit>> { _ -> Result.success(Unit) }
val clock = FakeSystemClock(epochMillisResult = 1_000L)
val manager = createManager(
client = FakeMatrixClient(
sessionId = A_SESSION_ID,
sessionCoroutineScope = backgroundScope,
ownBeaconInfoUpdates = beaconInfoUpdates,
).apply {
givenGetRoomResult(
A_ROOM_ID,
FakeJoinedRoom(
startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
stopLiveLocationShareResult = stopLiveLocationShareResult,
sendLiveLocationResult = sendLiveLocationResult,
),
)
},
coordinator = createCoordinator(),
liveLocationStore = liveLocationStore,
clock = clock,
)
advanceUntilIdle()
val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) }
beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
assertThat(startResult.await().isSuccess).isTrue()
// Advance the clock past the expiry so the next location tick is beyond the deadline.
clock.epochMillisResult = 1_000L + 1.minutes.inWholeMilliseconds + 1_000L
manager.sharingRoomIds.test {
assertThat(awaitItem()).containsExactly(A_ROOM_ID)
manager.onLocationUpdate(io.element.android.features.location.api.Location(lat = 0.0, lon = 0.0, accuracy = null))
assertThat(awaitItem()).isEmpty()
advanceUntilIdle()
}
assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
assert(sendLiveLocationResult).isNeverCalled()
// stopLiveLocationShare is called twice: once defensively in startShare, once from the tick-triggered stopShare.
assert(stopLiveLocationShareResult).isCalledExactly(2)
}
@Test
fun `session deleted clears local state`() = runTest {
val startServiceRecorder = lambdaRecorder<Unit> { }
@@ -59,7 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
override suspend fun setup() {
if (cryptoObject != null) return
Timber.d("Setup biometric authentication")
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
if (this == null) return false
return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray())
}.isSuccess
}.onFailure {
Timber.e(it, "Failed to validate cipher")
}
.isSuccess
}
}
@@ -15,15 +15,23 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -32,6 +40,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.pinDigitBg
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PinEntryTextField(
@@ -39,16 +49,25 @@ fun PinEntryTextField(
isSecured: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
initialIsFocus: Boolean = false,
) {
var isFocused by remember { mutableStateOf(initialIsFocus) }
val filledCount = pinEntry.digits.count { it is PinDigit.Filled }
val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field)
val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount)
BasicTextField(
modifier = modifier,
modifier = modifier
.onFocusChanged { isFocused = it.isFocused }
.clearAndSetSemantics {
contentDescription = "$pinFieldLabel, $digitsEnteredLabel"
},
value = pinEntry.toText(),
onValueChange = {
onValueChange(it)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
decorationBox = {
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused)
}
)
}
@@ -58,13 +77,19 @@ fun PinEntryTextField(
private fun PinEntryRow(
pinEntry: PinEntry,
isSecured: Boolean,
isFocused: Boolean,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit, isSecured = isSecured)
val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty }
pinEntry.digits.forEachIndexed { index, digit ->
PinDigitView(
digit = digit,
isSecured = isSecured,
isFocused = isFocused && index == focusedIndex
)
}
}
}
@@ -73,11 +98,16 @@ private fun PinEntryRow(
private fun PinDigitView(
digit: PinDigit,
isSecured: Boolean,
isFocused: Boolean,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
PinDigit.Empty -> {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
if (isFocused) {
Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape)
} else {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
}
}
is PinDigit.Filled -> {
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
@@ -108,18 +138,19 @@ private fun PinDigitView(
internal fun PinEntryTextFieldPreview() {
ElementPreview {
val pinEntry = PinEntry.createEmpty(4).fillWith("12")
Column {
PinEntryTextField(
pinEntry = pinEntry,
isSecured = true,
onValueChange = {},
)
Spacer(modifier = Modifier.size(16.dp))
PinEntryTextField(
pinEntry = pinEntry,
isSecured = false,
onValueChange = {},
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
listOf(true, false).forEach { isSecured ->
listOf(true, false).forEach { initialIsFocus ->
PinEntryTextField(
pinEntry = pinEntry,
isSecured = isSecured,
initialIsFocus = initialIsFocus,
onValueChange = {},
)
}
}
}
}
}

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