Merge pull request #295 from ouchadam/tech/login-state

Tech/Converting login view model to state/reducer pattern
This commit is contained in:
Adam Brown 2022-12-13 20:56:47 +00:00 committed by GitHub
commit 3d98a979fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 366 additions and 110 deletions

@ -1 +1 @@
Subproject commit 8139eaaf57cee4ce9d0616d617e8aff7eb1480e3
Subproject commit cdf3e1bffba4b69dd8f752c6cc7588b0e89a17af

View File

@ -4,7 +4,7 @@ import app.dapk.st.core.ProvidableModule
import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.LoginViewModel
import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.state.ProfileState
class HomeModule(
@ -13,7 +13,7 @@ class HomeModule(
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : ProvidableModule {
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profile: ProfileState): HomeViewModel {
internal fun homeViewModel(directory: DirectoryState, login: LoginState, profile: ProfileState): HomeViewModel {
return HomeViewModel(
chatEngine,
directory,

View File

@ -7,9 +7,7 @@ import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreCleaner
import app.dapk.st.engine.ChatEngine
import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.login.LoginViewModel
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.isSignedIn
import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.viewmodel.DapkViewModel
@ -22,7 +20,7 @@ import kotlinx.coroutines.launch
internal class HomeViewModel(
private val chatEngine: ChatEngine,
private val directoryState: DirectoryState,
private val loginViewModel: LoginViewModel,
private val loginState: LoginState,
private val profileState: ProfileState,
private val cacheCleaner: StoreCleaner,
private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
@ -33,7 +31,7 @@ internal class HomeViewModel(
private var listenForInvitesJob: Job? = null
fun directory() = directoryState
fun login() = loginViewModel
fun login() = loginState
fun profile() = profileState
fun start() {

View File

@ -23,10 +23,10 @@ import kotlinx.coroutines.flow.onEach
class MainActivity : DapkActivity() {
private val directoryViewModel by state { module<DirectoryModule>().directoryState() }
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
private val profileViewModel by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }
private val directoryState by state { module<DirectoryModule>().directoryState() }
private val loginState by state { module<LoginModule>().loginState() }
private val profileState by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryState, loginState, profileState) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -8,9 +8,17 @@ android {
dependencies {
implementation "chat-engine:chat-engine"
implementation 'screen-state:screen-android'
implementation project(":domains:android:compose-core")
implementation project(":domains:android:push")
implementation project(":domains:android:viewmodel")
implementation project(":design-library")
implementation project(":core")
kotlinTest(it)
testImplementation 'screen-state:state-test'
testImplementation 'chat-engine:chat-engine-test'
androidImportFixturesWorkaround(project, project(":core"))
}

View File

@ -3,7 +3,11 @@ package app.dapk.st.login
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.state.LoginState
import app.dapk.st.login.state.LoginUseCase
import app.dapk.st.login.state.loginReducer
import app.dapk.st.push.PushModule
import app.dapk.st.state.createStateViewModel
class LoginModule(
private val chatEngine: ChatEngine,
@ -11,7 +15,10 @@ class LoginModule(
private val errorTracker: ErrorTracker,
) : ProvidableModule {
fun loginViewModel(): LoginViewModel {
return LoginViewModel(chatEngine, pushModule.pushTokenRegistrar(), errorTracker)
fun loginState(): LoginState {
return createStateViewModel {
val loginUseCase = LoginUseCase(chatEngine, pushModule.pushTokenRegistrars(), errorTracker)
loginReducer(loginUseCase, it)
}
}
}

View File

@ -33,15 +33,18 @@ import androidx.compose.ui.unit.sp
import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.GenericError
import app.dapk.st.login.LoginEvent.LoginComplete
import app.dapk.st.login.LoginScreenState.*
import app.dapk.st.login.state.LoginAction
import app.dapk.st.login.state.LoginEvent.LoginComplete
import app.dapk.st.login.state.LoginEvent.WellKnownMissing
import app.dapk.st.login.state.LoginScreenState
import app.dapk.st.login.state.LoginState
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
loginViewModel.ObserveEvents(onLoggedIn)
fun LoginScreen(loginState: LoginState, onLoggedIn: () -> Unit) {
loginState.ObserveEvents(onLoggedIn)
LaunchedEffect(true) {
loginViewModel.start()
loginState.dispatch(LoginAction.ComponentLifecycle.Visible)
}
var userName by rememberSaveable { mutableStateOf("") }
@ -49,11 +52,12 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
var serverUrl by rememberSaveable { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current
when (val state = loginViewModel.state) {
is Error -> GenericError(cause = state.cause, action = { loginViewModel.start() })
Loading -> CenteredLoading()
when (val content = loginState.current.content) {
is LoginScreenState.Content.Error -> GenericError(cause = content.cause, action = { loginState.dispatch(LoginAction.ComponentLifecycle.Visible) })
LoginScreenState.Content.Loading -> CenteredLoading()
is Content ->
is LoginScreenState.Content.Idle -> {
val showServerUrl = loginState.current.showServerUrl
Row {
Spacer(modifier = Modifier.weight(0.1f))
Column(
@ -88,7 +92,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Email, imeAction = ImeAction.Next)
)
val canDoLoginAttempt = if (state.showServerUrl) {
val canDoLoginAttempt = if (showServerUrl) {
userName.isNotEmpty() && password.isNotEmpty() && serverUrl.isNotEmpty()
} else {
userName.isNotEmpty() && password.isNotEmpty()
@ -106,12 +110,12 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
Icon(imageVector = Icons.Outlined.Lock, contentDescription = null)
},
keyboardActions = KeyboardActions(
onDone = { loginViewModel.login(userName, password, serverUrl) },
onDone = { loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) },
onNext = { focusManager.moveFocus(FocusDirection.Down) },
),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { state.showServerUrl } ?: ImeAction.None,
imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { showServerUrl } ?: ImeAction.None,
keyboardType = KeyboardType.Password
),
visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(),
@ -123,7 +127,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
}
)
if (state.showServerUrl) {
if (showServerUrl) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = serverUrl,
@ -133,7 +137,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
leadingIcon = {
Icon(imageVector = Icons.Default.Web, contentDescription = null)
},
keyboardActions = KeyboardActions(onDone = { loginViewModel.login(userName, password, serverUrl) }),
keyboardActions = KeyboardActions(onDone = { loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) }),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.None,
@ -148,7 +152,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
onClick = {
keyboardController?.hide()
loginViewModel.login(userName, password, serverUrl)
loginState.dispatch(LoginAction.Login(userName, password, serverUrl))
},
enabled = canDoLoginAttempt
) {
@ -159,6 +163,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
private fun Modifier.autofill(
@ -183,13 +188,13 @@ private fun Modifier.autofill(
}
@Composable
private fun LoginViewModel.ObserveEvents(onLoggedIn: () -> Unit) {
private fun LoginState.ObserveEvents(onLoggedIn: () -> Unit) {
val context = LocalContext.current
StartObserving {
this@ObserveEvents.events.launch {
when (it) {
LoginComplete -> onLoggedIn()
LoginEvent.WellKnownMissing -> {
WellKnownMissing -> {
Toast.makeText(context, "Couldn't find the homeserver, please enter the server URL", Toast.LENGTH_LONG).show()
}
}

View File

@ -1,14 +0,0 @@
package app.dapk.st.login
sealed interface LoginScreenState {
data class Content(val showServerUrl: Boolean) : LoginScreenState
object Loading : LoginScreenState
data class Error(val cause: Throwable) : LoginScreenState
}
sealed interface LoginEvent {
object LoginComplete : LoginEvent
object WellKnownMissing : LoginEvent
}

View File

@ -1,65 +0,0 @@
package app.dapk.st.login
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.logP
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.st.login.LoginEvent.LoginComplete
import app.dapk.st.login.LoginScreenState.*
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
class LoginViewModel(
private val chatEngine: ChatEngine,
private val pushTokenRegistrar: PushTokenRegistrar,
private val errorTracker: ErrorTracker,
) : DapkViewModel<LoginScreenState, LoginEvent>(
initialState = Content(showServerUrl = false)
) {
private var previousState: LoginScreenState? = null
fun login(userName: String, password: String, serverUrl: String?) {
state = Loading
viewModelScope.launch {
logP("login") {
when (val result = chatEngine.login(LoginRequest(userName, password, serverUrl.takeIfNotEmpty()))) {
is LoginResult.Success -> {
runCatching {
listOf(
async { pushTokenRegistrar.registerCurrentToken() },
async { preloadMe() },
).awaitAll()
}
_events.tryEmit(LoginComplete)
}
is LoginResult.Error -> {
errorTracker.track(result.cause)
state = Error(result.cause)
}
LoginResult.MissingWellKnown -> {
_events.tryEmit(LoginEvent.WellKnownMissing)
state = Content(showServerUrl = true)
}
}
}
}
}
private suspend fun preloadMe() = chatEngine.me(forceRefresh = false)
fun start() {
val showServerUrl = previousState?.let { it is Content && it.showServerUrl } ?: false
state = Content(showServerUrl = showServerUrl)
}
}
private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() }

View File

@ -0,0 +1,15 @@
package app.dapk.st.login.state
import app.dapk.state.Action
sealed interface LoginAction : Action {
sealed interface ComponentLifecycle : LoginAction {
object Visible : ComponentLifecycle
}
data class Login(val userName: String, val password: String, val serverUrl: String?) : LoginAction
data class UpdateContent(val content: LoginScreenState.Content) : LoginAction
data class UpdateState(val state: LoginScreenState) : LoginAction
}

View File

@ -0,0 +1,46 @@
package app.dapk.st.login.state
import app.dapk.st.core.logP
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.state.async
import app.dapk.state.change
import app.dapk.state.createReducer
import kotlinx.coroutines.launch
fun loginReducer(
loginUseCase: LoginUseCase,
eventEmitter: suspend (LoginEvent) -> Unit,
) = createReducer(
initialState = LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle),
change(LoginAction.ComponentLifecycle.Visible::class) { _, state ->
LoginScreenState(state.showServerUrl, content = LoginScreenState.Content.Idle)
},
change(LoginAction.UpdateContent::class) { action, state -> state.copy(content = action.content) },
change(LoginAction.UpdateState::class) { action, _ -> action.state },
async(LoginAction.Login::class) { action ->
coroutineScope.launch {
logP("login") {
dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Loading))
val request = LoginRequest(action.userName, action.password, action.serverUrl.takeIfNotEmpty())
when (val result = loginUseCase.login(request)) {
is LoginResult.Error -> dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Error(result.cause)))
LoginResult.MissingWellKnown -> {
eventEmitter.invoke(LoginEvent.WellKnownMissing)
dispatch(LoginAction.UpdateState(LoginScreenState(showServerUrl = true, content = LoginScreenState.Content.Idle)))
}
is LoginResult.Success -> eventEmitter.invoke(LoginEvent.LoginComplete)
}
}
}
},
)
private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() }

View File

@ -0,0 +1,22 @@
package app.dapk.st.login.state
import app.dapk.st.state.State
typealias LoginState = State<LoginScreenState, LoginEvent>
data class LoginScreenState(
val showServerUrl: Boolean,
val content: Content,
) {
sealed interface Content {
object Idle : Content
object Loading : Content
data class Error(val cause: Throwable) : Content
}
}
sealed interface LoginEvent {
object LoginComplete : LoginEvent
object WellKnownMissing : LoginEvent
}

View File

@ -0,0 +1,44 @@
package app.dapk.st.login.state
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.logP
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.st.push.PushTokenRegistrar
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class LoginUseCase(
private val chatEngine: ChatEngine,
private val pushTokenRegistrar: PushTokenRegistrar,
private val errorTracker: ErrorTracker,
) {
suspend fun login(request: LoginRequest): LoginResult {
return logP("login") {
when (val result = chatEngine.login(request)) {
is LoginResult.Success -> {
coroutineScope {
runCatching {
listOf(
async { pushTokenRegistrar.registerCurrentToken() },
async { chatEngine.preloadMe() },
).awaitAll()
}
result
}
}
is LoginResult.Error -> {
errorTracker.track(result.cause)
result
}
LoginResult.MissingWellKnown -> result
}
}
}
private suspend fun ChatEngine.preloadMe() = this.me(forceRefresh = false)
}

View File

@ -0,0 +1,103 @@
package app.dapk.st.login.state
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.st.login.state.fakes.FakeLoginUseCase
import app.dapk.st.matrix.common.DeviceId
import app.dapk.st.matrix.common.HomeServerUrl
import app.dapk.st.matrix.common.UserCredentials
import fixture.aUserId
import org.junit.Test
import test.assertDispatches
import test.assertEvents
import test.assertOnlyDispatches
import test.testReducer
private val A_LOGIN_ACTION = LoginAction.Login(
userName = "a-username",
password = "a-password",
serverUrl = "a-server-url",
)
private val AN_ERROR_CAUSE = RuntimeException()
class LoginReducerTest {
private val fakeLoginUseCase = FakeLoginUseCase()
private val runReducerTest = testReducer { events: (LoginEvent) -> Unit ->
loginReducer(fakeLoginUseCase.instance, events)
}
@Test
fun `initial state is idle without server url`() = runReducerTest {
assertInitialState(LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle))
}
@Test
fun `given non initial state, when Visible, then updates state to Idle with previous showServerUrl`() = runReducerTest {
setState(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Loading))
reduce(LoginAction.ComponentLifecycle.Visible)
assertOnlyStateChange(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Idle))
}
@Test
fun `when UpdateContent, then only updates content state`() = runReducerTest {
reduce(LoginAction.UpdateContent(LoginScreenState.Content.Loading))
assertOnlyStateChange {
it.copy(content = LoginScreenState.Content.Loading)
}
}
@Test
fun `when UpdateState, then only updates state`() = runReducerTest {
reduce(LoginAction.UpdateState(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Loading)))
assertOnlyStateChange(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Loading))
}
@Test
fun `given login errors, when Login, then updates content with loading and error`() = runReducerTest {
fakeLoginUseCase.given(A_LOGIN_ACTION.toRequest()).returns(LoginResult.Error(AN_ERROR_CAUSE))
reduce(A_LOGIN_ACTION)
assertOnlyDispatches(
LoginAction.UpdateContent(LoginScreenState.Content.Loading),
LoginAction.UpdateContent(LoginScreenState.Content.Error(AN_ERROR_CAUSE)),
)
}
@Test
fun `given login fails with WellKnownMissing, when Login, then emits WellKnownMissing event and updates content with loading and Idle showing server url`() =
runReducerTest {
fakeLoginUseCase.given(A_LOGIN_ACTION.toRequest()).returns(LoginResult.MissingWellKnown)
reduce(A_LOGIN_ACTION)
assertDispatches(
LoginAction.UpdateContent(LoginScreenState.Content.Loading),
LoginAction.UpdateState(LoginScreenState(showServerUrl = true, LoginScreenState.Content.Idle)),
)
assertEvents(LoginEvent.WellKnownMissing)
assertNoStateChange()
}
@Test
fun `given login success, when Login, then emits LoginComplete event`() = runReducerTest {
fakeLoginUseCase.given(A_LOGIN_ACTION.toRequest()).returns(LoginResult.Success(aUserCredentials()))
reduce(A_LOGIN_ACTION)
assertDispatches(LoginAction.UpdateContent(LoginScreenState.Content.Loading))
assertEvents(LoginEvent.LoginComplete)
assertNoStateChange()
}
}
private fun LoginAction.Login.toRequest() = LoginRequest(this.userName, this.password, this.serverUrl)
private fun aUserCredentials() = UserCredentials("ignored", HomeServerUrl("ignored"), aUserId(), DeviceId("ignored"))

View File

@ -0,0 +1,72 @@
package app.dapk.st.login.state
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.st.matrix.common.DeviceId
import app.dapk.st.matrix.common.HomeServerUrl
import app.dapk.st.matrix.common.UserCredentials
import app.dapk.st.push.PushTokenRegistrar
import fake.FakeChatEngine
import fake.FakeErrorTracker
import fixture.aUserId
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import test.expect
private val A_LOGIN_ERROR = LoginResult.Error(RuntimeException())
private val A_LOGIN_SUCCESS = LoginResult.Success(aUserCredentials())
private val A_LOGIN_REQUEST = LoginRequest(
userName = "a-username",
password = "a-password",
serverUrl = "a-server-url",
)
class LoginUseCaseTest {
private val fakeChatEngine = FakeChatEngine()
private val fakePushTokenRegistrar = FakePushTokenRegistrar()
private val fakeErrorTracker = FakeErrorTracker()
private val useCase = LoginUseCase(
fakeChatEngine,
fakePushTokenRegistrar,
fakeErrorTracker,
)
@Test
fun `when logging in succeeds, then registers push token and preload me`() = runTest {
fakeChatEngine.givenLogin(A_LOGIN_REQUEST).returns(A_LOGIN_SUCCESS)
fakePushTokenRegistrar.expect { it.registerCurrentToken() }
fakeChatEngine.expect { it.me(forceRefresh = false) }
val result = useCase.login(A_LOGIN_REQUEST)
result shouldBeEqualTo A_LOGIN_SUCCESS
}
@Test
fun `when logging in fails with MissingWellKnown, then does nothing`() = runTest {
fakeChatEngine.givenLogin(A_LOGIN_REQUEST).returns(LoginResult.MissingWellKnown)
val result = useCase.login(A_LOGIN_REQUEST)
result shouldBeEqualTo LoginResult.MissingWellKnown
}
@Test
fun `when logging in errors, then tracks cause`() = runTest {
fakeChatEngine.givenLogin(A_LOGIN_REQUEST).returns(A_LOGIN_ERROR)
fakeErrorTracker.expect { it.track(A_LOGIN_ERROR.cause) }
val result = useCase.login(A_LOGIN_REQUEST)
result shouldBeEqualTo A_LOGIN_ERROR
}
}
class FakePushTokenRegistrar : PushTokenRegistrar by mockk()
private fun aUserCredentials() = UserCredentials("ignored", HomeServerUrl("ignored"), aUserId(), DeviceId("ignored"))

View File

@ -0,0 +1,15 @@
package app.dapk.st.login.state.fakes
import app.dapk.st.engine.LoginRequest
import app.dapk.st.login.state.LoginUseCase
import io.mockk.coEvery
import io.mockk.mockk
import test.delegateReturn
class FakeLoginUseCase {
val instance = mockk<LoginUseCase>()
fun given(loginRequest: LoginRequest) = coEvery { instance.login(loginRequest) }.delegateReturn()
}