Add account recover (#635)

This commit is contained in:
doteq
2020-02-27 00:10:11 +01:00
committed by GitHub
parent 96c1bb4c69
commit 18d6ec6961
21 changed files with 867 additions and 35 deletions

View File

@ -0,0 +1,19 @@
package io.github.wulkanowy.data.repositories.recover
import io.github.wulkanowy.sdk.Sdk
import io.reactivex.Single
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecoverRemote @Inject constructor(private val sdk: Sdk) {
fun getReCaptchaSiteKey(host: String, symbol: String): Single<Pair<String, String>> {
return sdk.getPasswordResetCaptchaCode(host, symbol)
}
fun sendRecoverRequest(url: String, symbol: String, email: String, reCaptchaResponse: String): Single<String> {
return sdk.sendPasswordResetRequest(url, symbol, email, reCaptchaResponse)
}
}

View File

@ -0,0 +1,26 @@
package io.github.wulkanowy.data.repositories.recover
import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork
import com.github.pwittchen.reactivenetwork.library.rx2.internet.observing.InternetObservingSettings
import io.reactivex.Single
import java.net.UnknownHostException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecoverRepository @Inject constructor(private val settings: InternetObservingSettings, private val remote: RecoverRemote) {
fun getReCaptchaSiteKey(host: String, symbol: String): Single<Pair<String, String>> {
return ReactiveNetwork.checkInternetConnectivity(settings).flatMap {
if (it) remote.getReCaptchaSiteKey(host, symbol)
else Single.error(UnknownHostException())
}
}
fun sendRecoverRequest(url: String, symbol: String, email: String, reCaptchaResponse: String): Single<String> {
return ReactiveNetwork.checkInternetConnectivity(settings).flatMap {
if (it) remote.sendRecoverRequest(url, symbol, email, reCaptchaResponse)
else Single.error(UnknownHostException())
}
}
}

View File

@ -10,6 +10,7 @@ import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.base.BaseFragmentPagerAdapter
import io.github.wulkanowy.ui.modules.login.advanced.LoginAdvancedFragment
import io.github.wulkanowy.ui.modules.login.form.LoginFormFragment
import io.github.wulkanowy.ui.modules.login.recover.LoginRecoverFragment
import io.github.wulkanowy.ui.modules.login.studentselect.LoginStudentSelectFragment
import io.github.wulkanowy.ui.modules.login.symbol.LoginSymbolFragment
import io.github.wulkanowy.utils.setOnSelectPageListener
@ -52,7 +53,8 @@ class LoginActivity : BaseActivity<LoginPresenter>(), LoginView {
LoginFormFragment.newInstance(),
LoginSymbolFragment.newInstance(),
LoginStudentSelectFragment.newInstance(),
LoginAdvancedFragment.newInstance()
LoginAdvancedFragment.newInstance(),
LoginRecoverFragment.newInstance()
))
}
@ -99,4 +101,8 @@ class LoginActivity : BaseActivity<LoginPresenter>(), LoginView {
fun onAdvancedLoginClick() {
presenter.onAdvancedLoginClick()
}
fun onRecoverClick() {
presenter.onRecoverClick()
}
}

View File

@ -43,5 +43,8 @@ class LoginErrorHandler @Inject constructor(
super.clear()
onBadCredentials = {}
onStudentDuplicate = {}
onInvalidToken = {}
onInvalidPin = {}
onInvalidSymbol = {}
}
}

View File

@ -8,6 +8,7 @@ import io.github.wulkanowy.di.scopes.PerFragment
import io.github.wulkanowy.ui.base.BaseFragmentPagerAdapter
import io.github.wulkanowy.ui.modules.login.advanced.LoginAdvancedFragment
import io.github.wulkanowy.ui.modules.login.form.LoginFormFragment
import io.github.wulkanowy.ui.modules.login.recover.LoginRecoverFragment
import io.github.wulkanowy.ui.modules.login.studentselect.LoginStudentSelectFragment
import io.github.wulkanowy.ui.modules.login.symbol.LoginSymbolFragment
@ -37,4 +38,8 @@ internal abstract class LoginModule {
@PerFragment
@ContributesAndroidInjector
abstract fun bindLoginSelectStudentFragment(): LoginStudentSelectFragment
@PerFragment
@ContributesAndroidInjector
abstract fun bindLoginRecoverFragment(): LoginRecoverFragment
}

View File

@ -49,11 +49,15 @@ class LoginPresenter @Inject constructor(
view?.switchView(3)
}
fun onRecoverClick() {
view?.switchView(4)
}
fun onViewSelected(index: Int) {
view?.apply {
when (index) {
0 -> showActionBar(false)
1, 2 -> showActionBar(true)
1, 2, 3, 4 -> showActionBar(true)
}
}
}
@ -62,7 +66,7 @@ class LoginPresenter @Inject constructor(
Timber.i("Back pressed in login view")
view?.apply {
when (currentViewIndex) {
1, 2, 3 -> switchView(0)
1, 2, 3, 4 -> switchView(0)
else -> default()
}
}

View File

@ -3,6 +3,8 @@ package io.github.wulkanowy.ui.modules.login.advanced
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
@ -98,8 +100,9 @@ class LoginAdvancedFragment : BaseFragment(), LoginAdvancedView {
loginFormSymbol.setAdapter(ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, resources.getStringArray(R.array.symbols_values)))
with(loginFormHost) {
setText(hostKeys.getOrElse(0) { "" })
setText(hostKeys.getOrNull(0).orEmpty())
setAdapter(LoginSymbolAdapter(context, R.layout.support_simple_spinner_dropdown_item, hostKeys))
setOnClickListener { if (loginFormContainer.visibility == GONE) dismissDropDown() }
}
}
@ -212,30 +215,30 @@ class LoginAdvancedFragment : BaseFragment(), LoginAdvancedView {
}
override fun showOnlyHybridModeInputs() {
loginFormUsernameLayout.visibility = View.VISIBLE
loginFormPassLayout.visibility = View.VISIBLE
loginFormHostLayout.visibility = View.VISIBLE
loginFormPinLayout.visibility = View.GONE
loginFormSymbolLayout.visibility = View.VISIBLE
loginFormTokenLayout.visibility = View.GONE
loginFormUsernameLayout.visibility = VISIBLE
loginFormPassLayout.visibility = VISIBLE
loginFormHostLayout.visibility = VISIBLE
loginFormPinLayout.visibility = GONE
loginFormSymbolLayout.visibility = VISIBLE
loginFormTokenLayout.visibility = GONE
}
override fun showOnlyScrapperModeInputs() {
loginFormUsernameLayout.visibility = View.VISIBLE
loginFormPassLayout.visibility = View.VISIBLE
loginFormHostLayout.visibility = View.VISIBLE
loginFormPinLayout.visibility = View.GONE
loginFormSymbolLayout.visibility = View.VISIBLE
loginFormTokenLayout.visibility = View.GONE
loginFormUsernameLayout.visibility = VISIBLE
loginFormPassLayout.visibility = VISIBLE
loginFormHostLayout.visibility = VISIBLE
loginFormPinLayout.visibility = GONE
loginFormSymbolLayout.visibility = VISIBLE
loginFormTokenLayout.visibility = GONE
}
override fun showOnlyMobileApiModeInputs() {
loginFormUsernameLayout.visibility = View.GONE
loginFormPassLayout.visibility = View.GONE
loginFormHostLayout.visibility = View.GONE
loginFormPinLayout.visibility = View.VISIBLE
loginFormSymbolLayout.visibility = View.VISIBLE
loginFormTokenLayout.visibility = View.VISIBLE
loginFormUsernameLayout.visibility = GONE
loginFormPassLayout.visibility = GONE
loginFormHostLayout.visibility = GONE
loginFormPinLayout.visibility = VISIBLE
loginFormSymbolLayout.visibility = VISIBLE
loginFormTokenLayout.visibility = VISIBLE
}
override fun showSoftKeyboard() {
@ -247,11 +250,11 @@ class LoginAdvancedFragment : BaseFragment(), LoginAdvancedView {
}
override fun showProgress(show: Boolean) {
loginFormProgress.visibility = if (show) View.VISIBLE else View.GONE
loginFormProgress.visibility = if (show) VISIBLE else GONE
}
override fun showContent(show: Boolean) {
loginFormContainer.visibility = if (show) View.VISIBLE else View.GONE
loginFormContainer.visibility = if (show) VISIBLE else GONE
}
override fun notifyParentAccountLogged(students: List<Student>) {

View File

@ -74,14 +74,15 @@ class LoginFormFragment : BaseFragment(), LoginFormView {
loginFormPrivacyLink.setOnClickListener { presenter.onPrivacyLinkClick() }
loginFormFaq.setOnClickListener { presenter.onFaqClick() }
loginFormContactEmail.setOnClickListener { presenter.onEmailClick() }
loginFormRecoverLink.setOnClickListener { presenter.onRecoverClick() }
loginFormPass.setOnEditorActionListener { _, id, _ ->
if (id == IME_ACTION_DONE || id == IME_NULL) loginFormSignIn.callOnClick() else false
}
with(loginFormHost) {
setText(hostKeys.getOrElse(0) { "" })
setText(hostKeys.getOrNull(0).orEmpty())
setAdapter(LoginSymbolAdapter(context, R.layout.support_simple_spinner_dropdown_item, hostKeys))
setOnClickListener { if (loginFormContainer.visibility == GONE) dismissDropDown() }
}
}
@ -167,6 +168,10 @@ class LoginFormFragment : BaseFragment(), LoginFormView {
(activity as? LoginActivity)?.onAdvancedLoginClick()
}
override fun onRecoverClick() {
(activity as? LoginActivity)?.onRecoverClick()
}
override fun onDestroyView() {
super.onDestroyView()
presenter.onDetachView()

View File

@ -108,6 +108,10 @@ class LoginFormPresenter @Inject constructor(
view?.openEmail()
}
fun onRecoverClick() {
view?.onRecoverClick()
}
private fun validateCredentials(login: String, password: String): Boolean {
var isCorrect = true

View File

@ -54,4 +54,6 @@ interface LoginFormView : BaseView {
fun openEmail()
fun openAdvancedLogin()
fun onRecoverClick()
}

View File

@ -0,0 +1,209 @@
package io.github.wulkanowy.ui.modules.login.recover
import android.annotation.SuppressLint
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.ArrayAdapter
import androidx.core.widget.doOnTextChanged
import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.login.LoginActivity
import io.github.wulkanowy.ui.modules.login.form.LoginSymbolAdapter
import io.github.wulkanowy.utils.hideSoftInput
import io.github.wulkanowy.utils.showSoftInput
import kotlinx.android.synthetic.main.fragment_login_recover.*
import javax.inject.Inject
class LoginRecoverFragment : BaseFragment(), LoginRecoverView {
@Inject
lateinit var presenter: LoginRecoverPresenter
companion object {
fun newInstance() = LoginRecoverFragment()
}
private lateinit var hostKeys: Array<String>
private lateinit var hostValues: Array<String>
override val recoverHostValue: String
get() = hostValues.getOrNull(hostKeys.indexOf(loginRecoverHost.text.toString())).orEmpty()
override val recoverNameValue: String
get() = loginRecoverName.text.toString().trim()
override val recoverSymbolValue: String
get() = loginRecoverSymbol.text.toString().trim()
override val emailHintString: String
get() = getString(R.string.login_email_hint)
override val loginPeselEmailHintString: String
get() = getString(R.string.login_login_pesel_email_hint)
override val invalidEmailString: String
get() = getString(R.string.login_invalid_email)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_login_recover, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
presenter.onAttachView(this)
}
override fun initView() {
loginRecoverWebView.setBackgroundColor(Color.TRANSPARENT)
hostKeys = resources.getStringArray(R.array.hosts_keys)
hostValues = resources.getStringArray(R.array.hosts_values)
loginRecoverName.doOnTextChanged { _, _, _, _ -> presenter.onNameTextChanged() }
loginRecoverHost.setOnItemClickListener { _, _, _, _ -> presenter.onHostSelected() }
loginRecoverButton.setOnClickListener { presenter.onRecoverClick() }
loginRecoverErrorRetry.setOnClickListener { presenter.onRecoverClick() }
loginRecoverErrorDetails.setOnClickListener { presenter.onDetailsClick() }
loginRecoverLogin.setOnClickListener { (activity as LoginActivity).switchView(0) }
loginRecoverSymbol.setAdapter(ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, resources.getStringArray(R.array.symbols_values)))
with(loginRecoverHost) {
setText(hostKeys.getOrNull(0).orEmpty())
setAdapter(LoginSymbolAdapter(context, R.layout.support_simple_spinner_dropdown_item, hostKeys))
setOnClickListener { if (loginRecoverFormContainer.visibility == GONE) dismissDropDown() }
}
}
override fun setDefaultCredentials(username: String, symbol: String) {
loginRecoverName.setText(username)
loginRecoverSymbol.setText(symbol)
}
override fun setErrorNameRequired() {
with(loginRecoverNameLayout) {
requestFocus()
error = getString(R.string.login_field_required)
}
}
override fun setUsernameHint(hint: String) {
loginRecoverNameLayout.hint = hint
}
override fun setUsernameError(message: String) {
with(loginRecoverNameLayout) {
requestFocus()
error = message
}
}
override fun clearUsernameError() {
loginRecoverNameLayout.error = null
}
override fun showSymbol(show: Boolean) {
loginRecoverSymbolLayout.visibility = if (show) VISIBLE else GONE
}
override fun showProgress(show: Boolean) {
loginRecoverProgress.visibility = if (show) VISIBLE else GONE
}
override fun showRecoverForm(show: Boolean) {
loginRecoverFormContainer.visibility = if (show) VISIBLE else GONE
}
override fun showCaptcha(show: Boolean) {
loginRecoverCaptchaContainer.visibility = if (show) VISIBLE else GONE
}
override fun showErrorView(show: Boolean) {
loginRecoverError.visibility = if (show) VISIBLE else GONE
}
override fun setErrorDetails(message: String) {
loginRecoverErrorMessage.text = message
}
override fun showSuccessView(show: Boolean) {
loginRecoverSuccess.visibility = if (show) VISIBLE else GONE
}
override fun setSuccessTitle(title: String) {
loginRecoverSuccessTitle.text = title
}
override fun setSuccessMessage(message: String) {
loginRecoverSuccessMessage.text = message
}
override fun showSoftKeyboard() {
activity?.showSoftInput()
}
override fun hideSoftKeyboard() {
activity?.hideSoftInput()
}
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
override fun loadReCaptcha(siteKey: String, url: String) {
val html = """
<div style="position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);" id="recaptcha"></div>
<script src="https://www.google.com/recaptcha/api.js?onload=cl&render=explicit&hl=pl" async defer></script>
<script>var cl=()=>grecaptcha.render("recaptcha",{
sitekey:'$siteKey',
callback:e =>Android.captchaCallback(e)})</script>
""".trimIndent()
with(loginRecoverWebView) {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
private var recoverWebViewSuccess: Boolean = true
override fun onPageFinished(view: WebView?, url: String?) {
if (recoverWebViewSuccess) {
showCaptcha(true)
showProgress(false)
} else {
showProgress(false)
showErrorView(true)
}
}
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
recoverWebViewSuccess = false
}
}
loadDataWithBaseURL(url, html, "text/html", "UTF-8", null)
addJavascriptInterface(object {
@JavascriptInterface
fun captchaCallback(reCaptchaResponse: String) {
activity?.runOnUiThread {
presenter.onReCaptchaVerified(reCaptchaResponse)
}
}
}, "Android")
}
}
override fun onResume() {
super.onResume()
presenter.updateFields()
}
override fun onDestroyView() {
super.onDestroyView()
loginRecoverWebView.destroy()
presenter.onDetachView()
}
}

View File

@ -0,0 +1,157 @@
package io.github.wulkanowy.ui.modules.login.recover
import io.github.wulkanowy.data.repositories.recover.RecoverRepository
import io.github.wulkanowy.data.repositories.student.StudentRepository
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.utils.FirebaseAnalyticsHelper
import io.github.wulkanowy.utils.SchedulersProvider
import io.github.wulkanowy.utils.ifNullOrBlank
import timber.log.Timber
import javax.inject.Inject
class LoginRecoverPresenter @Inject constructor(
schedulers: SchedulersProvider,
studentRepository: StudentRepository,
private val loginErrorHandler: RecoverErrorHandler,
private val analytics: FirebaseAnalyticsHelper,
private val recoverRepository: RecoverRepository
) : BasePresenter<LoginRecoverView>(loginErrorHandler, studentRepository, schedulers) {
private lateinit var lastError: Throwable
override fun onAttachView(view: LoginRecoverView) {
super.onAttachView(view)
view.initView()
with(loginErrorHandler) {
showErrorMessage = ::showErrorMessage
onInvalidUsername = ::onInvalidUsername
onInvalidCaptcha = ::onInvalidCaptcha
}
}
fun onNameTextChanged() {
view?.clearUsernameError()
}
fun onHostSelected() {
view?.run {
if ("fakelog" in recoverHostValue) setDefaultCredentials("jan@fakelog.cf", "Default")
clearUsernameError()
updateFields()
}
}
fun updateFields() {
view?.run {
if ("fakelog" in recoverHostValue || "vulcan" in recoverHostValue) {
showSymbol(true)
setUsernameHint(emailHintString)
} else {
showSymbol(false)
setUsernameHint(loginPeselEmailHintString)
}
}
}
fun onRecoverClick() {
val username = view?.recoverNameValue.orEmpty()
val host = view?.recoverHostValue.orEmpty()
val symbol = view?.recoverSymbolValue.ifNullOrBlank { "Default" }
if (username.isEmpty()) {
view?.setErrorNameRequired()
return
}
if (("fakelog" in host || "vulcan" in host) && "@" !in username) {
view?.setUsernameError(view?.invalidEmailString.orEmpty())
return
}
disposable.add(recoverRepository.getReCaptchaSiteKey(host, symbol)
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.doOnSubscribe {
view?.run {
hideSoftKeyboard()
showRecoverForm(false)
showProgress(true)
showErrorView(false)
showCaptcha(false)
}
}
.subscribe({ (resetUrl, siteKey) ->
view?.loadReCaptcha(siteKey, resetUrl)
}) {
Timber.e("Obtain captcha site key result: An exception occurred")
errorHandler.dispatch(it)
})
}
fun onReCaptchaVerified(reCaptchaResponse: String) {
val username = view?.recoverNameValue.orEmpty()
val host = view?.recoverHostValue.orEmpty()
val symbol = view?.recoverSymbolValue.ifNullOrBlank { "Default" }
with(disposable) {
clear()
add(recoverRepository.sendRecoverRequest(host, symbol, username, reCaptchaResponse)
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.doOnSubscribe {
view?.run {
showProgress(true)
showRecoverForm(false)
showCaptcha(false)
}
}
.doFinally {
view?.showProgress(false)
}
.subscribe({
view?.run {
showSuccessView(true)
setSuccessTitle(it.substringBefore(". "))
setSuccessMessage(it.substringAfter(". "))
}
analytics.logEvent("account_recover", "register" to host, "symbol" to symbol, "success" to true)
}) {
Timber.e("Send recover request result: An exception occurred")
errorHandler.dispatch(it)
analytics.logEvent("account_recover", "register" to host, "symbol" to symbol, "success" to false)
})
}
}
fun onDetailsClick() {
view?.showErrorDetailsDialog(lastError)
}
private fun showErrorMessage(message: String, error: Throwable) {
view?.run {
lastError = error
showProgress(false)
setErrorDetails(message)
showErrorView(true)
}
}
private fun onInvalidUsername(message: String) {
view?.run {
setUsernameError(message)
showRecoverForm(true)
}
}
private fun onInvalidCaptcha(message: String, error: Throwable) {
view?.run {
lastError = error
setErrorDetails(message)
showCaptcha(false)
showRecoverForm(false)
showErrorView(true)
}
}
}

View File

@ -0,0 +1,54 @@
package io.github.wulkanowy.ui.modules.login.recover
import io.github.wulkanowy.ui.base.BaseView
interface LoginRecoverView : BaseView {
val recoverHostValue: String
val recoverNameValue: String
val recoverSymbolValue: String
val emailHintString: String
val loginPeselEmailHintString: String
val invalidEmailString: String
fun initView()
fun setDefaultCredentials(username: String, symbol: String)
fun clearUsernameError()
fun showSymbol(show: Boolean)
fun setErrorNameRequired()
fun setUsernameHint(hint: String)
fun setUsernameError(message: String)
fun showSoftKeyboard()
fun hideSoftKeyboard()
fun showProgress(show: Boolean)
fun showRecoverForm(show: Boolean)
fun showCaptcha(show: Boolean)
fun showErrorView(show: Boolean)
fun setErrorDetails(message: String)
fun showSuccessView(show: Boolean)
fun setSuccessMessage(message: String)
fun setSuccessTitle(title: String)
fun loadReCaptcha(siteKey: String, url: String)
}

View File

@ -0,0 +1,32 @@
package io.github.wulkanowy.ui.modules.login.recover
import android.content.res.Resources
import com.readystatesoftware.chuck.api.ChuckCollector
import io.github.wulkanowy.sdk.scrapper.exception.InvalidCaptchaException
import io.github.wulkanowy.sdk.scrapper.exception.InvalidEmailException
import io.github.wulkanowy.sdk.scrapper.exception.NoAccountFoundException
import io.github.wulkanowy.ui.base.ErrorHandler
import javax.inject.Inject
class RecoverErrorHandler @Inject constructor(
resources: Resources,
chuckCollector: ChuckCollector
) : ErrorHandler(resources, chuckCollector) {
var onInvalidUsername: (String) -> Unit = {}
var onInvalidCaptcha: (String, Throwable) -> Unit = { _, _ -> }
override fun proceed(error: Throwable) {
when (error) {
is InvalidEmailException, is NoAccountFoundException -> onInvalidUsername(error.localizedMessage.orEmpty())
is InvalidCaptchaException -> onInvalidCaptcha(error.localizedMessage.orEmpty(), error)
else -> super.proceed(error)
}
}
override fun clear() {
super.clear()
onInvalidUsername = {}
}
}