mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-06 07:24:49 +00:00
Merge branch 'develop' into misc/reactions-pinned-messages
This commit is contained in:
+1
-1
@@ -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" />
|
||||
|
||||
+1
-2
@@ -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 {
|
||||
|
||||
+21
-16
@@ -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)
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
+63
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+208
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+29
@@ -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>
|
||||
|
||||
@@ -17,4 +17,5 @@ dependencies {
|
||||
implementation(projects.libraries.compound)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.wellknown.api)
|
||||
}
|
||||
|
||||
+13
-1
@@ -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 = "*"
|
||||
}
|
||||
|
||||
+2
@@ -12,5 +12,7 @@ interface SessionEnterpriseService {
|
||||
suspend fun isElementCallAvailable(): Boolean
|
||||
suspend fun tweakMasUrl(url: String): String
|
||||
|
||||
suspend fun isEncryptionDisabledByHomeserver(): Boolean
|
||||
|
||||
suspend fun init()
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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)
|
||||
|
||||
+7
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
+30
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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)
|
||||
}
|
||||
|
||||
+15
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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)
|
||||
}
|
||||
}
|
||||
+17
@@ -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>
|
||||
|
||||
+23
-14
@@ -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 ->
|
||||
|
||||
+135
-7
@@ -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
|
||||
|
||||
+14
-11
@@ -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(
|
||||
|
||||
+2
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
+2
@@ -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) ||
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
|
||||
+7
-7
@@ -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() },
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
+8
-7
@@ -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>
|
||||
|
||||
+43
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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 = {
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+4
-8
@@ -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)
|
||||
|
||||
+6
-2
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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() },
|
||||
|
||||
+9
-3
@@ -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 {
|
||||
|
||||
+17
-5
@@ -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.")
|
||||
|
||||
+14
@@ -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) {
|
||||
|
||||
+19
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+8
-7
@@ -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>
|
||||
|
||||
+26
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-8
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+25
-9
@@ -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()
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+47
@@ -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> { }
|
||||
|
||||
+5
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+49
-18
@@ -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
Reference in New Issue
Block a user