Move SignedOut classes to their own module.

This commit is contained in:
Benoit Marty 2023-10-10 20:04:34 +02:00
parent 8bbcb973c4
commit 8f1ccfccf2
13 changed files with 179 additions and 12 deletions

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultSignedOutEntryPoint @Inject constructor() : SignedOutEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): SignedOutEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : SignedOutEntryPoint.NodeBuilder {
override fun params(params: SignedOutEntryPoint.Params): SignedOutEntryPoint.NodeBuilder {
plugins += SignedOutNode.Inputs(params.sessionId)
return this
}
override fun build(): Node {
return parentNode.createNode<SignedOutNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
sealed interface SignedOutEvents {
data object SignInAgain : SignedOutEvents
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(AppScope::class)
class SignedOutNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SignedOutPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val sessionId: SessionId,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.sessionId.value)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SignedOutView(
state = state,
modifier = modifier
)
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.launch
class SignedOutPresenter @AssistedInject constructor(
@Assisted private val sessionId: String, /* Cannot inject SessionId */
private val sessionStore: SessionStore,
) : Presenter<SignedOutState> {
@AssistedFactory
interface Factory {
fun create(sessionId: String): SignedOutPresenter
}
@Composable
override fun present(): SignedOutState {
val sessions by sessionStore.sessionsFlow().collectAsState(initial = emptyList())
val signedOutSession by remember {
derivedStateOf { sessions.firstOrNull { it.userId == sessionId } }
}
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SignedOutEvents) {
when (event) {
SignedOutEvents.SignInAgain -> coroutineScope.launch {
sessionStore.removeSession(sessionId)
}
}
}
return SignedOutState(
signedOutSession = signedOutSession,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import io.element.android.libraries.sessionstorage.api.SessionData
// Do not use default value, so no member get forgotten in the presenters.
data class SignedOutState(
val signedOutSession: SessionData?,
val eventSink: (SignedOutEvents) -> Unit,
)

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
open class SignedOutStateProvider : PreviewParameterProvider<SignedOutState> {
override val values: Sequence<SignedOutState>
get() = sequenceOf(
aSignedOutState(),
// Add other states here
)
}
fun aSignedOutState() = SignedOutState(
eventSink = {},
signedOutSession = aSessionData()
)
fun aSessionData(
sessionId: SessionId = SessionId("@alice:server.org"),
isTokenValid: Boolean = false,
): SessionData {
return SessionData(
userId = sessionId.value,
deviceId = "aDeviceId",
accessToken = "anAccessToken",
refreshToken = "aRefreshToken",
homeserverUrl = "aHomeserverUrl",
oidcData = null,
slidingSyncProxy = null,
loginTimestamp = null,
isTokenValid = isTokenValid,
loginType = LoginType.UNKNOWN,
)
}

View file

@ -0,0 +1,152 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.persistentListOf
@Composable
fun SignedOutView(
state: SignedOutState,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = { state.eventSink(SignedOutEvents.SignInAgain) })
HeaderFooterPage(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
header = { SignedOutHeader() },
content = { SignedOutContent() },
footer = {
SignedOutFooter(
onSignInAgain = { state.eventSink(SignedOutEvents.SignInAgain) },
)
}
)
}
@Composable
private fun SignedOutHeader() {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
title = "Youre signed out",
subTitle = "It can be due to various reasons:",
iconImageVector = Icons.Filled.AccountCircle
)
}
@Composable
private fun SignedOutContent(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = BiasAlignment(
horizontalBias = 0f,
verticalBias = -0.4f
)
) {
InfoListOrganism(
items = persistentListOf(
InfoListItem(
message = "Youve changed your password on another session.",
iconComposable = { CheckIcon() },
),
InfoListItem(
message = "You have deleted this session from another session.",
iconComposable = { CheckIcon() },
),
InfoListItem(
message = "The administrator of your server has invalidated your access for security reason.",
iconComposable = { CheckIcon() },
),
),
textStyle = ElementTheme.typography.fontBodyMdMedium,
iconTint = ElementTheme.colors.textPrimary,
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
)
}
}
@Composable
private fun CheckIcon(modifier: Modifier = Modifier) {
Icon(
modifier = modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
resourceId = CommonDrawables.ic_compound_check,
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
@Composable
private fun SignedOutFooter(
modifier: Modifier = Modifier,
onSignInAgain: () -> Unit,
) {
ButtonColumnMolecule(
modifier = modifier,
) {
Button(
text = "Sign in again",
onClick = onSignInAgain,
modifier = Modifier.fillMaxWidth(),
)
}
}
@PreviewsDayNight
@Composable
internal fun SignedOutViewPreview(
@PreviewParameter(SignedOutStateProvider::class) state: SignedOutState,
) = ElementPreview {
SignedOutView(
state = state,
)
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.signedout.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SignedOutPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val aSessionData = aSessionData()
val sessionStore = InMemorySessionStore().apply {
storeData(aSessionData)
}
val presenter = createPresenter(sessionStore = sessionStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.signedOutSession).isEqualTo(aSessionData)
}
}
@Test
fun `present - sign in again`() = runTest {
val aSessionData = aSessionData()
val sessionStore = InMemorySessionStore().apply {
storeData(aSessionData)
}
val presenter = createPresenter(sessionStore = sessionStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.signedOutSession).isEqualTo(aSessionData)
assertThat(sessionStore.getAllSessions()).isNotEmpty()
initialState.eventSink(SignedOutEvents.SignInAgain)
assertThat(awaitItem().signedOutSession).isNull()
assertThat(sessionStore.getAllSessions()).isEmpty()
}
}
private fun createPresenter(
sessionId: SessionId = A_SESSION_ID,
sessionStore: SessionStore = InMemorySessionStore(),
): SignedOutPresenter {
return SignedOutPresenter(
sessionId = sessionId.value,
sessionStore = sessionStore,
)
}
}