mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
Merge pull request #7692 from element-hq/bma/fix7689
Fix blank screen at launch when the homeserver expires the sliding sync session
This commit is contained in:
@@ -96,6 +96,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -113,6 +114,7 @@ import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toKotlinDuration
|
||||
@@ -324,7 +326,9 @@ class LoggedInFlowNode(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Placeholder -> emptyNode(buildContext)
|
||||
// Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all,
|
||||
// since there is no guarantee that this state will be left.
|
||||
NavTarget.Placeholder -> emptyNode(buildContext, state = EmptyState(delayBeforeShowingContent = 500.milliseconds))
|
||||
NavTarget.LoggedInPermanent -> {
|
||||
val callback = object : LoggedInNode.Callback {
|
||||
override fun navigateToNotificationTroubleshoot() {
|
||||
|
||||
@@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -60,6 +64,12 @@ class SyncOrchestrator(
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Number of consecutive attempts at restarting a sync service which stopped on its own.
|
||||
* Reset once the sync service has been running for [RESTART_BACKOFF_RESET_DELAY].
|
||||
*/
|
||||
private val restartAttempt = AtomicInteger(0)
|
||||
|
||||
/**
|
||||
* Starting observing the app state and network state to start/stop the sync service.
|
||||
*
|
||||
@@ -91,6 +101,17 @@ class SyncOrchestrator(
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun observeStates() = coroutineScope.launch {
|
||||
Timber.tag(tag).d("start observing the app and network state")
|
||||
// Reset the restart backoff once the sync service has been steadily running for a while. Note that
|
||||
// SyncService.startSync() moves the state to Running right away, so resetting on any Running value
|
||||
// would defeat the backoff when the sync service dies again immediately after each restart.
|
||||
syncService.syncState
|
||||
.debounce(RESTART_BACKOFF_RESET_DELAY)
|
||||
.onEach { syncState ->
|
||||
if (syncState == SyncState.Running) {
|
||||
restartAttempt.set(0)
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
val isAppActiveFlows = listOf(
|
||||
appForegroundStateService.isInForeground,
|
||||
appForegroundStateService.isInCall,
|
||||
@@ -117,14 +138,26 @@ class SyncOrchestrator(
|
||||
SyncStateAction.StopSync
|
||||
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
|
||||
SyncStateAction.StartSync
|
||||
} else if (syncState == SyncState.Error && isAppActive && isNetworkAvailable) {
|
||||
// The sync service has given up, for instance because the homeserver has expired our sliding sync
|
||||
// session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here,
|
||||
// else the application is stuck until its data is cleared.
|
||||
SyncStateAction.RestartSync
|
||||
} else {
|
||||
SyncStateAction.NoOp
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce { action ->
|
||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
|
||||
when (action) {
|
||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||
SyncStateAction.StopSync -> 3.seconds
|
||||
// Back off before restarting a dead sync service, to avoid hammering a failing homeserver.
|
||||
// A state change in the meantime cancels the pending restart.
|
||||
SyncStateAction.RestartSync -> restartDelay(restartAttempt.get())
|
||||
SyncStateAction.StartSync,
|
||||
SyncStateAction.NoOp -> 0.seconds
|
||||
}
|
||||
}
|
||||
.onCompletion {
|
||||
Timber.tag(tag).d("has been stopped")
|
||||
@@ -134,6 +167,11 @@ class SyncOrchestrator(
|
||||
SyncStateAction.StartSync -> {
|
||||
syncService.startSync()
|
||||
}
|
||||
SyncStateAction.RestartSync -> {
|
||||
val attempt = restartAttempt.incrementAndGet()
|
||||
Timber.tag(tag).w("sync service is dead, restarting it (attempt $attempt)")
|
||||
syncService.startSync()
|
||||
}
|
||||
SyncStateAction.StopSync -> {
|
||||
syncService.stopSync()
|
||||
}
|
||||
@@ -143,8 +181,35 @@ class SyncOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal val FIRST_RESTART_DELAY = 1.seconds
|
||||
|
||||
/**
|
||||
* Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [FIRST_RESTART_DELAY * 32].
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal val RESTART_BACKOFF_DELAYS = listOf(
|
||||
FIRST_RESTART_DELAY,
|
||||
FIRST_RESTART_DELAY * 2,
|
||||
FIRST_RESTART_DELAY * 4,
|
||||
FIRST_RESTART_DELAY * 8,
|
||||
FIRST_RESTART_DELAY * 16,
|
||||
FIRST_RESTART_DELAY * 32,
|
||||
)
|
||||
|
||||
/**
|
||||
* Duration during which the sync service has to stay running before the restart backoff is reset.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds
|
||||
|
||||
private fun restartDelay(attempt: Int): Duration {
|
||||
return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)]
|
||||
}
|
||||
|
||||
private enum class SyncStateAction {
|
||||
StartSync,
|
||||
RestartSync,
|
||||
StopSync,
|
||||
NoOp,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.appnav
|
||||
|
||||
import io.element.android.appnav.session.FIRST_RESTART_DELAY
|
||||
import io.element.android.appnav.session.RESTART_BACKOFF_DELAYS
|
||||
import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY
|
||||
import io.element.android.appnav.session.SyncOrchestrator
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
@@ -414,6 +417,131 @@ class SyncOrchestratorTest {
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service is in error, it will be restarted after a delay`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// The restart does not happen right away, we back off first
|
||||
advanceTimeBy(FIRST_RESTART_DELAY / 2)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// But it does happen once the backoff has elapsed
|
||||
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(FIRST_RESTART_DELAY * 2)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// The sync service starts, but dies again straight away
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
advanceTimeBy(200.milliseconds)
|
||||
syncService.emitSyncState(SyncState.Error)
|
||||
|
||||
// The second restart is not attempted after the first delay anymore
|
||||
advanceTimeBy(FIRST_RESTART_DELAY + 200.milliseconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// But after twice that delay
|
||||
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||
startSyncRecorder.assertions().isCalledExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service recovers, the delay between the restarts is reset`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(FIRST_RESTART_DELAY * 2)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// The sync service is now running, and keeps running for a while
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
advanceTimeBy(RESTART_BACKOFF_RESET_DELAY + 1.seconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// It dies again, and is restarted after the first delay, not a longer one
|
||||
syncService.emitSyncState(SyncState.Error)
|
||||
advanceTimeBy(FIRST_RESTART_DELAY / 2)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||
startSyncRecorder.assertions().isCalledExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service is in error but the app is in background, it is not restarted`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service is in error but there is no network, it is not restarted`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
private fun TestScope.createSyncOrchestrator(
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
|
||||
+6
-1
@@ -32,11 +32,13 @@ import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
@@ -84,7 +86,10 @@ class FtueFlowNode(
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Placeholder -> {
|
||||
emptyNode(buildContext)
|
||||
// Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState).
|
||||
// This can last for a while, or forever if the sync service never manages to run, so
|
||||
// render a loading state rather than nothing at all.
|
||||
emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds))
|
||||
}
|
||||
is NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
|
||||
@@ -18,4 +18,5 @@ dependencies {
|
||||
implementation(libs.appyx.core)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
}
|
||||
|
||||
+9
-24
@@ -8,38 +8,23 @@
|
||||
|
||||
package io.element.android.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.node
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
|
||||
*
|
||||
* @param buildContext the [BuildContext] of the Node.
|
||||
* @param state the [EmptyState] of the Node. Default value will render an empty node forever.
|
||||
*/
|
||||
fun emptyNode(
|
||||
buildContext: BuildContext,
|
||||
state: EmptyState = EmptyState(Duration.INFINITE),
|
||||
): Node = node(buildContext) { modifier ->
|
||||
EmptyView(modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyView(
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
)
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmptyViewPreview() = ElementPreview {
|
||||
EmptyView(Modifier)
|
||||
EmptyView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.ui.common.nodes
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* @property delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for
|
||||
* placeholders which can stay on screen for a long time, or forever, else the application looks frozen.
|
||||
*/
|
||||
data class EmptyState(
|
||||
val delayBeforeShowingContent: Duration,
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
open class EmptyStatePreviewParam : PreviewParameterProvider<EmptyState> {
|
||||
override val values: Sequence<EmptyState>
|
||||
get() = sequenceOf(
|
||||
EmptyState(Duration.INFINITE),
|
||||
EmptyState(1.seconds),
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun EmptyView(
|
||||
state: EmptyState,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.delayBeforeShowingContent.isFinite()) {
|
||||
DelayedVisibility(duration = state.delayBeforeShowingContent) {
|
||||
LoadingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() = Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_loading),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmptyViewPreview(
|
||||
@PreviewParameter(EmptyStatePreviewParam::class) state: EmptyState
|
||||
) = ElementPreview {
|
||||
EmptyView(state = state)
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user