Skip to content

Commit

Permalink
implement viewmodel for Login
Browse files Browse the repository at this point in the history
  • Loading branch information
westnordost committed Mar 15, 2024
1 parent 05bbdc5 commit 9b65eee
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 271 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package de.westnordost.streetcomplete.screens.user

import de.westnordost.streetcomplete.screens.user.login.LoginViewModel
import de.westnordost.streetcomplete.screens.user.login.LoginViewModelImpl
import de.westnordost.streetcomplete.screens.user.profile.ProfileViewModel
import de.westnordost.streetcomplete.screens.user.profile.ProfileViewModelImpl
import de.westnordost.streetcomplete.screens.user.statistics.EditStatisticsViewModel
Expand All @@ -12,5 +14,7 @@ val userScreenModule = module {
get(), get(), get(), get(), get(), get(), get(named("AvatarsCacheDirectory")), get()
) }

factory<LoginViewModel> { LoginViewModelImpl(get(), get(), get(), get()) }

factory<EditStatisticsViewModel> { EditStatisticsViewModelImpl(get(), get()) }
}
Original file line number Diff line number Diff line change
@@ -1,88 +1,101 @@
package de.westnordost.streetcomplete.screens.user.login

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import de.westnordost.streetcomplete.ApplicationConstants
import de.westnordost.streetcomplete.R
import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource
import de.westnordost.streetcomplete.data.user.UserLoginStatusController
import de.westnordost.streetcomplete.data.user.UserUpdater
import de.westnordost.streetcomplete.databinding.FragmentLoginBinding
import de.westnordost.streetcomplete.screens.HasTitle
import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope
import de.westnordost.streetcomplete.screens.user.login.LoginError.*
import de.westnordost.streetcomplete.util.ktx.observe
import de.westnordost.streetcomplete.util.ktx.toast
import de.westnordost.streetcomplete.util.viewBinding
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.util.Locale

/** Shows only a login button and a text that clarifies that login is necessary for publishing the
* answers. */
class LoginFragment :
Fragment(R.layout.fragment_login),
HasTitle,
OAuthFragment.Listener {

private val unsyncedChangesCountSource: UnsyncedChangesCountSource by inject()
private val userLoginStatusController: UserLoginStatusController by inject()
private val userUpdater: UserUpdater by inject()
/** Leads user through the OAuth 2 auth flow to login */
class LoginFragment : Fragment(R.layout.fragment_login), HasTitle {

override val title: String get() = getString(R.string.user_login)

private val viewModel by viewModel<LoginViewModel>()
private val binding by viewBinding(FragmentLoginBinding::bind)

private val webViewClient: OAuthWebViewClient = OAuthWebViewClient()

private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
binding.webView.goBack()
}
}

@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.loginButton.setOnClickListener { pushOAuthFragment() }

requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback)

binding.webView.settings.userAgentString = ApplicationConstants.USER_AGENT
binding.webView.settings.javaScriptEnabled = true
binding.webView.settings.allowContentAccess = true
binding.webView.settings.setSupportZoom(false)
binding.webView.webViewClient = webViewClient

binding.loginButton.setOnClickListener { viewModel.startLogin() }

val launchAuth = arguments?.getBoolean(ARG_LAUNCH_AUTH, false) ?: false
if (launchAuth) {
pushOAuthFragment()
viewModel.startLogin()
}
}

override fun onStart() {
super.onStart()

viewLifecycleScope.launch {
val unsyncedChanges = unsyncedChangesCountSource.getCount()
binding.unpublishedEditCountText.text = getString(R.string.unsynced_quests_not_logged_in_description, unsyncedChanges)
binding.unpublishedEditCountText.isGone = unsyncedChanges <= 0
observe(viewModel.unsyncedChangesCount) { count ->
binding.unpublishedEditCountText.text = getString(R.string.unsynced_quests_not_logged_in_description, count)
binding.unpublishedEditCountText.isGone = count <= 0
}
observe(viewModel.loginState) { state ->
binding.loginButtonContainer.isInvisible = state !is LoggedOut
binding.webView.isInvisible = state !is RequestingAuthorization
// fragment is dismissed on login, so while it is still there, show progress spinner
binding.progressView.isInvisible = state !is RetrievingAccessToken && state !is LoggedIn

if (state is RequestingAuthorization) {
binding.webView.loadUrl(
viewModel.authorizationRequestUrl,
mutableMapOf("Accept-Language" to Locale.getDefault().toLanguageTag())
)
} else if (state is LoginError) {
activity?.toast(when (state) {
RequiredPermissionsNotGranted -> R.string.oauth_failed_permissions
CommunicationError -> R.string.oauth_communication_error
}, Toast.LENGTH_LONG)

viewModel.resetLogin()
}
}
}

/* ------------------------------- OAuthFragment.Listener ----------------------------------- */

override fun onOAuthSuccess(accessToken: String) {
binding.loginButton.visibility = View.INVISIBLE
binding.loginProgress.visibility = View.VISIBLE
childFragmentManager.popBackStack("oauth", POP_BACK_STACK_INCLUSIVE)
userLoginStatusController.logIn(accessToken)
userUpdater.update()
binding.loginProgress.visibility = View.INVISIBLE
override fun onPause() {
super.onPause()
binding.webView.onPause()
}

override fun onOAuthFailed(e: Exception?) {
childFragmentManager.popBackStack("oauth", POP_BACK_STACK_INCLUSIVE)
userLoginStatusController.logOut()
override fun onResume() {
super.onResume()
binding.webView.onResume()
}

/* ------------------------------------------------------------------------------------------ */

private fun pushOAuthFragment() {
childFragmentManager.commit {
setCustomAnimations(
R.anim.enter_from_end, R.anim.exit_to_start,
R.anim.enter_from_start, R.anim.exit_to_end
)
replace<OAuthFragment>(R.id.oauthFragmentContainer)
addToBackStack("oauth")
}
}

companion object {
fun create(launchAuth: Boolean = false): LoginFragment {
val f = LoginFragment()
Expand All @@ -92,4 +105,28 @@ class LoginFragment :

private const val ARG_LAUNCH_AUTH = "launch_auth"
}

private inner class OAuthWebViewClient : WebViewClient() {

@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView?, url: String): Boolean {
if (!viewModel.isAuthorizationResponseUrl(url)) return false
viewModel.finishAuthorization(url)
return true
}

@Deprecated("Deprecated in Java")
override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, url: String?) {
viewModel.failAuthorization(url.toString(), errorCode, description)
}

override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
binding.progressView.visibility = View.VISIBLE
backPressedCallback.isEnabled = view.canGoBack()
}

override fun onPageFinished(view: WebView?, url: String?) {
binding.progressView.visibility = View.INVISIBLE
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package de.westnordost.streetcomplete.screens.user.login

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow

abstract class LoginViewModel : ViewModel() {
abstract val unsyncedChangesCount: StateFlow<Int>

abstract val loginState: StateFlow<LoginState>

abstract val authorizationRequestUrl: String

/** Starts the OAuth2 based login flow. */
abstract fun startLogin()

/** Call when the web view / browser received an error when loading the (authorization) page */
abstract fun failAuthorization(url: String, errorCode: Int, description: String?)

/** Returns whether the url is a redirect url destined for this OAuth authorization flow */
abstract fun isAuthorizationResponseUrl(url: String): Boolean

/** Continues OAuth authorization flow with given redirect url */
abstract fun finishAuthorization(authorizationResponseUrl: String)

/** Resets the login state to LoggedOut. Only works if current state is LoginError */
abstract fun resetLogin()
}

sealed interface LoginState
data object LoggedOut : LoginState
data object RequestingAuthorization : LoginState
data object RetrievingAccessToken : LoginState
enum class LoginError : LoginState {
RequiredPermissionsNotGranted,
CommunicationError
}
data object LoggedIn : LoginState

Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package de.westnordost.streetcomplete.screens.user.login

import de.westnordost.streetcomplete.data.UnsyncedChangesCountSource
import de.westnordost.streetcomplete.data.user.OAUTH2_AUTHORIZATION_URL
import de.westnordost.streetcomplete.data.user.OAUTH2_CLIENT_ID
import de.westnordost.streetcomplete.data.user.OAUTH2_REDIRECT_URI
import de.westnordost.streetcomplete.data.user.OAUTH2_REQUESTED_SCOPES
import de.westnordost.streetcomplete.data.user.OAUTH2_REQUIRED_SCOPES
import de.westnordost.streetcomplete.data.user.OAUTH2_TOKEN_URL
import de.westnordost.streetcomplete.data.user.UserLoginStatusController
import de.westnordost.streetcomplete.data.user.UserUpdater
import de.westnordost.streetcomplete.data.user.oauth.OAuthAuthorizationParams
import de.westnordost.streetcomplete.data.user.oauth.OAuthException
import de.westnordost.streetcomplete.data.user.oauth.OAuthService
import de.westnordost.streetcomplete.data.user.oauth.extractAuthorizationCode
import de.westnordost.streetcomplete.util.ktx.launch
import de.westnordost.streetcomplete.util.logs.Log
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext

class LoginViewModelImpl(
private val unsyncedChangesCountSource: UnsyncedChangesCountSource,
private val userLoginStatusController: UserLoginStatusController,
private val oAuthService: OAuthService,
private val userUpdater: UserUpdater
) : LoginViewModel() {
override val loginState = MutableStateFlow<LoginState>(LoggedOut)
override val unsyncedChangesCount = MutableStateFlow(0)

override val authorizationRequestUrl: String get() = oAuth.authorizationRequestUrl

private val oAuth = OAuthAuthorizationParams(
OAUTH2_AUTHORIZATION_URL,
OAUTH2_TOKEN_URL,
OAUTH2_CLIENT_ID,
OAUTH2_REQUESTED_SCOPES,
OAUTH2_REDIRECT_URI
)

init {
launch(IO) {
unsyncedChangesCount.update { unsyncedChangesCountSource.getCount() }
}
}

override fun startLogin() {
loginState.compareAndSet(LoggedOut, RequestingAuthorization)
}

override fun failAuthorization(url: String, errorCode: Int, description: String?) {
Log.e(TAG, "Error for URL " + url + if (description != null) ": $description" else "")
loginState.compareAndSet(RequestingAuthorization, LoginError.CommunicationError)
}

override fun isAuthorizationResponseUrl(url: String): Boolean =
oAuth.itsForMe(url)

override fun finishAuthorization(authorizationResponseUrl: String) {
launch {
val accessToken = retrieveAccessToken(authorizationResponseUrl)
if (accessToken != null) {
login(accessToken)
}
}
}

private suspend fun retrieveAccessToken(authorizationResponseUrl: String): String? {
try {
loginState.value = RetrievingAccessToken
val authorizationCode = extractAuthorizationCode(authorizationResponseUrl)
val accessTokenResponse = withContext(IO) {
oAuthService.retrieveAccessToken(oAuth, authorizationCode)
}
if (accessTokenResponse.grantedScopes?.containsAll(OAUTH2_REQUIRED_SCOPES) == false) {
loginState.value = LoginError.RequiredPermissionsNotGranted
return null
}
return accessTokenResponse.accessToken
} catch (e: Exception) {
if (e is OAuthException && e.error == "access_denied") {
loginState.value = LoginError.RequiredPermissionsNotGranted
} else {
Log.e(TAG, "Error during authorization", e)
loginState.value = LoginError.CommunicationError
}
return null
}
}

private suspend fun login(accessToken: String) {
loginState.value = LoggedIn
userLoginStatusController.logIn(accessToken)
userUpdater.update()
}

override fun resetLogin() {
if (loginState.value is LoginError) loginState.value = LoggedOut
}

companion object {
private const val TAG = "Login"
}
}
Loading

0 comments on commit 9b65eee

Please sign in to comment.