Refactor login activity and update to new credentials manageer

This commit is contained in:
Phillip Thelen 2025-01-29 15:15:51 +01:00
parent 584b274d85
commit ed65bcec6b
11 changed files with 427 additions and 487 deletions

View file

@ -137,7 +137,8 @@
android:hint="@string/username"
android:theme="@style/LoginEditTextTheme"
android:textColor="@color/white_75_alpha"
android:textColorHighlight="@color/white"/>
android:textColorHighlight="@color/white"
android:isCredential="true"/>
<EditText
android:id="@+id/email"
@ -178,7 +179,7 @@
android:autofillHints="newPassword"/>
<ProgressBar
android:id="@+id/PB_AsyncTask"
android:id="@+id/progress_view"
android:indeterminate="true"
android:visibility="gone"
android:layout_width="wrap_content"
@ -186,37 +187,22 @@
android:layout_gravity="center"/>
<Button
android:id="@+id/login_btn"
android:id="@+id/submit_button"
android:layout_width="match_parent"
android:layout_height="42dp"
android:text="@string/register_btn"
android:layout_marginTop="@dimen/spacing_xlarge"
style="@style/LoginButton"/>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_marginTop="@dimen/spacing_large"
android:layout_height="wrap_content">
<Button
<Button
android:id="@+id/google_login_button"
android:layout_width="match_parent"
android:layout_height="@dimen/diamond_button_height"
android:layout_height="42dp"
android:text="@string/login_btn_google"
android:layout_marginTop="@dimen/spacing_large"
android:drawableStart="@drawable/google_icon"
style="@style/LoginButton"/>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/google_login_progress"
style="@style/LoginButtonProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="@color/white" />
</RelativeLayout>
<Button
android:id="@+id/apple_login_button"
android:layout_width="match_parent"
@ -269,4 +255,4 @@
android:alpha="0" />
</FrameLayout>
</FrameLayout>
</FrameLayout>

View file

@ -1234,7 +1234,7 @@
<string name="removed_social_auth">Disconnected %s</string>
<string name="password_added">Password saved</string>
<string name="add_email_and_password">Add Email and Password</string>
<string name="password_not_matching">Password needs to be typed correctly twice</string>
<string name="password_not_matching">The entered passwords do not match</string>
<string name="email_invalid">Invalid Email address</string>
<string name="successful_purchase_generic">Purchase successful</string>
<string name="starting_objectives">Starting Objectives</string>
@ -1576,6 +1576,9 @@
<string name="habitoween" translatable="false">Habitoween</string>
<string name="turkey_day">Turkey Day</string>
<string name="search_equipment">Search Equipment</string>
<string name="auth_get_credentials_error">Error getting credentials for authentication.</string>
<string name="auth_invalid_credentials">Received invalid credentials.</string>
<string name="auth_unknown_error">Unknown error during authentication.</string>
<plurals name="you_x_others">
<item quantity="zero">You</item>

View file

@ -3,8 +3,20 @@ package com.habitrpg.android.habitica.extensions
import android.app.Activity
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.habitrpg.common.habitica.helpers.launchCatching
import kotlinx.coroutines.CoroutineScope
fun Activity.hideKeyboard() {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(window.decorView.windowToken, 0)
}
fun AppCompatActivity.lifecycleLaunchWhen(state: Lifecycle.State, function: suspend CoroutineScope.() -> Unit) {
lifecycleScope.launchCatching {
repeatOnLifecycle(state, function)
}
}

View file

@ -0,0 +1,16 @@
package com.habitrpg.android.habitica.extensions
enum class AuthenticationErrors {
// Google Auth
GET_CREDENTIALS_ERROR,
INVALID_CREDENTIALS,
// Validation
PASSWORD_TOO_SHORT,
PASSWORD_MISMATCH,
MISSING_FIELDS;
var minPasswordLength: Int = 6
val isValidationError: Boolean
get() = this == PASSWORD_TOO_SHORT || this == PASSWORD_MISMATCH || this == MISSING_FIELDS
}

View file

@ -92,8 +92,8 @@ class AppConfigManager(contentRepository: ContentRepository) :
return remoteConfig.getBoolean("showSubscriptionBanner")
}
fun minimumPasswordLength(): Long {
return remoteConfig.getLong("minimumPasswordLength")
fun minimumPasswordLength(): Int {
return remoteConfig.getLong("minimumPasswordLength").toInt()
}
fun enableTaskDisplayMode(): Boolean {

View file

@ -20,7 +20,9 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.preference.PreferenceManager
import com.habitrpg.android.habitica.HabiticaApplication
import com.habitrpg.android.habitica.R
@ -39,6 +41,7 @@ import com.habitrpg.common.habitica.extensions.isUsingNightModeResources
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.LanguageHelper
import com.habitrpg.common.habitica.helpers.launchCatching
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileNotFoundException
@ -314,4 +317,5 @@ abstract class BaseActivity : AppCompatActivity() {
overridePendingTransition(R.anim.activity_fade_in, R.anim.activity_fade_out)
startActivity(intent)
}
}

View file

@ -1,12 +1,11 @@
package com.habitrpg.android.habitica.ui.activities
import android.accounts.AccountManager
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
@ -15,127 +14,50 @@ import android.text.InputType
import android.text.SpannableString
import android.text.method.LinkMovementMethod
import android.text.style.UnderlineSpan
import android.view.MenuItem
import android.view.View
import android.view.Window
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import com.google.android.gms.tasks.Tasks
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.Wearable
import com.google.firebase.analytics.FirebaseAnalytics
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.data.ApiClient
import com.habitrpg.android.habitica.databinding.ActivityLoginBinding
import com.habitrpg.android.habitica.extensions.addCancelButton
import com.habitrpg.android.habitica.extensions.addOkButton
import com.habitrpg.android.habitica.extensions.lifecycleLaunchWhen
import com.habitrpg.android.habitica.extensions.updateStatusBarColor
import com.habitrpg.android.habitica.helpers.Analytics
import com.habitrpg.android.habitica.helpers.AppConfigManager
import com.habitrpg.android.habitica.helpers.EventCategory
import com.habitrpg.android.habitica.helpers.HitType
import com.habitrpg.android.habitica.ui.helpers.dismissKeyboard
import com.habitrpg.android.habitica.extensions.AuthenticationErrors
import com.habitrpg.android.habitica.ui.viewmodels.AuthenticationViewModel
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.launchCatching
import com.habitrpg.common.habitica.models.auth.UserAuthResponse
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class LoginActivity : BaseActivity() {
private lateinit var binding: ActivityLoginBinding
@Inject
lateinit var apiClient: ApiClient
@Inject
lateinit var sharedPrefs: SharedPreferences
val viewModel by viewModels<AuthenticationViewModel>()
@Inject
lateinit var configManager: AppConfigManager
@Inject
lateinit var viewModel: AuthenticationViewModel
private var isRegistering: Boolean = false
private var isShowingForm: Boolean = false
private val loginClick =
View.OnClickListener {
binding.PBAsyncTask.visibility = View.VISIBLE
if (isRegistering) {
registerWithPassword()
} else {
loginWithPassword()
}
}
private fun loginWithPassword() {
val username: String = binding.username.text.toString().trim { it <= ' ' }
val password: String = binding.password.text.toString()
if (username.isEmpty() || password.isEmpty()) {
showValidationError(R.string.login_validation_error_fieldsmissing)
return
}
lifecycleScope.launch(
ExceptionHandler.coroutine {
hideProgress()
ExceptionHandler.reportError(it)
}
) {
val response = apiClient.connectUser(username, password)
if (response != null) {
handleAuthResponse(response)
} else {
hideProgress()
}
}
}
private fun registerWithPassword() {
val username: String = binding.username.text.toString().trim { it <= ' ' }
val email: String = binding.email.text.toString().trim { it <= ' ' }
val password: String = binding.password.text.toString()
val confirmPassword: String = binding.confirmPassword.text.toString()
if (username.isEmpty() || password.isEmpty() || email.isEmpty() || confirmPassword.isEmpty()) {
showValidationError(R.string.login_validation_error_fieldsmissing)
return
}
if (password.length < configManager.minimumPasswordLength()) {
showValidationError(
getString(
R.string.password_too_short,
configManager.minimumPasswordLength()
)
)
return
}
lifecycleScope.launch(
ExceptionHandler.coroutine {
hideProgress()
ExceptionHandler.reportError(it)
}
) {
val response = apiClient.registerUser(username, email, password, confirmPassword)
if (response != null) {
handleAuthResponse(response)
} else {
hideProgress()
}
}
}
override fun getLayoutResId(): Int {
return R.layout.activity_login
}
@ -152,71 +74,96 @@ class LoginActivity : BaseActivity() {
// Set default values to avoid null-responses when requesting unedited settings
PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false)
binding.loginBtn.setOnClickListener(loginClick)
val content = SpannableString(binding.forgotPassword.text)
content.setSpan(UnderlineSpan(), 0, content.length, 0)
binding.forgotPassword.text = content
binding.privacyPolicy.movementMethod = LinkMovementMethod.getInstance()
this.isRegistering = true
val additionalData = HashMap<String, Any>()
additionalData["page"] = this.javaClass.simpleName
binding.backgroundContainer.post {
binding.backgroundContainer.scrollTo(
0,
binding.backgroundContainer.bottom
)
}
configureSpecialUI()
binding.backgroundContainer.isScrollable = false
window.statusBarColor = ContextCompat.getColor(this, R.color.black_20_alpha)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
setupOnClickListeners()
setupViewmodelObserving()
}
private fun setupViewmodelObserving() {
lifecycleLaunchWhen(Lifecycle.State.RESUMED) {
viewModel.showAuthProgress.collect { showProgress ->
binding.progressView.isVisible = showProgress
}
}
lifecycleLaunchWhen(Lifecycle.State.RESUMED) {
viewModel.isRegistering.collect { isRegistering ->
if (isRegistering) {
configureForRegistering()
} else {
configureForLogin()
}
}
}
lifecycleLaunchWhen(Lifecycle.State.RESUMED) {
viewModel.authenticationError
.filterNotNull()
.collect { showError(it) }
}
lifecycleLaunchWhen(Lifecycle.State.RESUMED) {
viewModel.authenticationSuccess
.filterNotNull()
.collect { didRegister ->
if (didRegister) {
startSetupActivity()
} else {
startMainActivity()
}
}
}
}
private fun setupOnClickListeners() {
onBackPressedDispatcher.addCallback(this) {
if (isShowingForm) {
hideForm()
} else {
finish()
}
}
binding.newGameButton.setOnClickListener { newGameButtonClicked() }
binding.showLoginButton.setOnClickListener { showLoginButtonClicked() }
binding.backButton.setOnClickListener { backButtonClicked() }
binding.forgotPassword.setOnClickListener { onForgotPasswordClicked() }
binding.googleLoginButton.setOnClickListener {
binding.googleLoginProgress.visibility = View.VISIBLE
viewModel.handleGoogleLogin(this, pickAccountResult)
viewModel.startGoogleAuth(this)
}
binding.submitButton.setOnClickListener {
if (viewModel.isRegistering.value) {
registerWithPassword()
} else {
loginWithPassword()
}
}
}
private fun configureSpecialUI() {
val content = SpannableString(binding.forgotPassword.text)
content.setSpan(UnderlineSpan(), 0, content.length, 0)
binding.forgotPassword.text = content
binding.privacyPolicy.movementMethod = LinkMovementMethod.getInstance()
binding.backgroundContainer.post {
binding.backgroundContainer.scrollTo(
0,
binding.backgroundContainer.bottom,
)
}
}
override fun loadTheme(
sharedPreferences: SharedPreferences,
forced: Boolean
forced: Boolean,
) {
super.loadTheme(sharedPreferences, forced)
window.updateStatusBarColor(R.color.black_20_alpha, false)
}
override fun onBackPressed() {
if (isShowingForm) {
hideForm()
} else {
super.onBackPressed()
}
}
private fun resetLayout() {
if (this.isRegistering) {
if (binding.email.visibility == View.GONE) {
show(binding.email)
}
if (binding.confirmPassword.visibility == View.GONE) {
show(binding.confirmPassword)
}
} else {
if (binding.email.visibility == View.VISIBLE) {
hide(binding.email)
}
if (binding.confirmPassword.visibility == View.VISIBLE) {
hide(binding.confirmPassword)
}
}
val isRegistering = viewModel.isRegistering.value
binding.email.isVisible = isRegistering
binding.confirmPassword.isVisible = isRegistering
}
private fun startMainActivity() {
@ -233,154 +180,104 @@ class LoginActivity : BaseActivity() {
finish()
}
private fun toggleRegistering() {
this.isRegistering = (!this.isRegistering)
this.setRegistering()
}
private fun setRegistering() {
if (this.isRegistering) {
binding.loginBtn.text = getString(R.string.register_btn)
binding.username.setHint(R.string.username)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
binding.username.setAutofillHints("newUsername")
binding.password.setAutofillHints("newPassword")
}
binding.password.imeOptions = EditorInfo.IME_ACTION_NEXT
binding.googleLoginButton.setText(R.string.register_btn_google)
} else {
binding.loginBtn.text = getString(R.string.login_btn)
binding.username.setHint(R.string.email_username)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
binding.username.setAutofillHints("username")
binding.password.setAutofillHints("password")
}
binding.password.imeOptions = EditorInfo.IME_ACTION_DONE
binding.googleLoginButton.setText(R.string.login_btn_google)
private fun configureForRegistering() {
binding.submitButton.text = getString(R.string.register_btn)
binding.username.setHint(R.string.username)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
binding.username.setAutofillHints("newUsername")
binding.password.setAutofillHints("newPassword")
}
binding.password.imeOptions = EditorInfo.IME_ACTION_NEXT
binding.googleLoginButton.setText(R.string.register_btn_google)
this.resetLayout()
}
private fun handleAuthResponse(response: UserAuthResponse) {
viewModel.handleAuthResponse(response)
try {
val messageClient: MessageClient = Wearable.getMessageClient(this)
val capabilityClient: CapabilityClient = Wearable.getCapabilityClient(this)
lifecycleScope.launch(Dispatchers.IO) {
try {
val info =
Tasks.await(
capabilityClient.getCapability(
"receive_message",
CapabilityClient.FILTER_REACHABLE
)
)
info.nodes.forEach {
Tasks.await(
messageClient.sendMessage(
it.id,
"/auth",
"${response.id}:${response.apiToken}".toByteArray()
)
)
}
} catch (e: Exception) {
// Wearable API is not available on this device.
private fun configureForLogin() {
binding.submitButton.text = getString(R.string.login_btn)
binding.username.setHint(R.string.email_username)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
binding.username.setAutofillHints("username")
binding.password.setAutofillHints("password")
}
binding.password.imeOptions = EditorInfo.IME_ACTION_DONE
binding.googleLoginButton.setText(R.string.login_btn_google)
this.resetLayout()
}
private fun loginWithPassword() {
val username: String = binding.username.text.toString()
val password: String = binding.password.text.toString()
viewModel.validateInputs(username, password)?.let {
showError(it)
return
}
viewModel.login(username, password)
}
private fun registerWithPassword() {
val username = binding.username.text.toString()
val email = binding.email.text.toString()
val password = binding.password.text.toString()
val confirmPassword = binding.confirmPassword.text.toString()
viewModel.validateInputs(username, password, email, confirmPassword)?.let {
showError(it)
return
}
viewModel.register(username, email, password, confirmPassword)
}
private fun sendAuthToWearables(response: UserAuthResponse) {
lifecycleScope.launch(Dispatchers.IO) {
val messageClient: MessageClient = Wearable.getMessageClient(this@LoginActivity)
val capabilityClient: CapabilityClient = Wearable.getCapabilityClient(this@LoginActivity)
try {
val info =
Tasks.await(
capabilityClient.getCapability(
"receive_message",
CapabilityClient.FILTER_REACHABLE,
),
)
info.nodes.forEach {
Tasks.await(
messageClient.sendMessage(
it.id,
"/auth",
"${response.id}:${response.apiToken}".toByteArray(),
),
)
}
}
} catch (e: Exception) {
// Wearable API is not available on this device.
}
handleAuthResponse(response.newUser)
}
private fun handleAuthResponse(isNew: Boolean) {
hideProgress()
dismissKeyboard()
if (isRegistering) {
FirebaseAnalytics.getInstance(this).logEvent("user_registered", null)
}
lifecycleScope.launch(ExceptionHandler.coroutine()) {
userRepository.retrieveUser(true, true)
if (isNew) {
startSetupActivity()
} else {
startMainActivity()
Analytics.sendEvent("login", EventCategory.BEHAVIOUR, HitType.EVENT)
} catch (_: Exception) {
// Wearable API is not available on this device.
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_toggleRegistering -> toggleRegistering()
}
return super.onOptionsItemSelected(item)
}
private fun hideProgress() {
runOnUiThread {
binding.googleLoginProgress.visibility = View.GONE
binding.PBAsyncTask.visibility = View.GONE
}
}
private fun showValidationError(resourceMessageString: Int) {
showValidationError(getString(resourceMessageString))
}
private fun showValidationError(message: String) {
binding.PBAsyncTask.visibility = View.GONE
private fun showError(error: AuthenticationErrors) {
val alert = HabiticaAlertDialog(this)
alert.setTitle(R.string.login_validation_error_title)
alert.setMessage(message)
if (error.isValidationError) {
alert.setTitle(R.string.login_validation_error_title)
} else {
alert.setTitle(R.string.authentication_error_title)
}
alert.setMessage(error.translatedMessage(this))
alert.addOkButton()
alert.show()
}
private val pickAccountResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
viewModel.googleEmail = it?.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
viewModel.handleGoogleLoginResult(
this,
recoverFromPlayServicesErrorResult
) { isNew ->
handleAuthResponse(isNew)
}
} else {
binding.googleLoginProgress.visibility = View.GONE
}
}
private val recoverFromPlayServicesErrorResult =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_CANCELED) {
viewModel.handleGoogleLoginResult(this, null) { isNew ->
handleAuthResponse(isNew)
}
}
}
private fun newGameButtonClicked() {
isRegistering = true
viewModel.isRegistering.value = true
showForm()
setRegistering()
}
private fun showLoginButtonClicked() {
isRegistering = false
viewModel.isRegistering.value = false
showForm()
setRegistering()
}
private fun backButtonClicked() {
if (isShowingForm) {
hideForm()
}
hideForm()
}
private fun showForm() {
@ -394,7 +291,7 @@ class LoginActivity : BaseActivity() {
val scaleLogoAnimation =
ValueAnimator.ofInt(
binding.logoView.measuredHeight,
(binding.logoView.measuredHeight * 0.75).toInt()
(binding.logoView.measuredHeight * 0.75).toInt(),
)
scaleLogoAnimation.addUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as? Int ?: 0
@ -402,7 +299,7 @@ class LoginActivity : BaseActivity() {
layoutParams.height = value
binding.logoView.layoutParams = layoutParams
}
if (isRegistering) {
if (viewModel.isRegistering.value) {
newGameAlphaAnimation.startDelay = 600
newGameAlphaAnimation.duration = 400
showLoginAlphaAnimation.duration = 400
@ -414,7 +311,7 @@ class LoginActivity : BaseActivity() {
binding.loginScrollview.visibility = View.VISIBLE
binding.loginScrollview.alpha = 1f
}
}
},
)
} else {
showLoginAlphaAnimation.startDelay = 600
@ -428,7 +325,7 @@ class LoginActivity : BaseActivity() {
binding.loginScrollview.visibility = View.VISIBLE
binding.loginScrollview.alpha = 1f
}
}
},
)
}
val backAlphaAnimation =
@ -438,7 +335,7 @@ class LoginActivity : BaseActivity() {
panAnimation,
newGameAlphaAnimation,
showLoginAlphaAnimation,
scaleLogoAnimation
scaleLogoAnimation,
)
showAnimation.play(backAlphaAnimation).after(panAnimation)
for (i in 0 until binding.formWrapper.childCount) {
@ -453,12 +350,15 @@ class LoginActivity : BaseActivity() {
}
private fun hideForm() {
if (!isShowingForm) {
return
}
isShowingForm = false
val panAnimation =
ObjectAnimator.ofInt(
binding.backgroundContainer,
"scrollY",
binding.backgroundContainer.bottom
binding.backgroundContainer.bottom,
).setDuration(1000)
val newGameAlphaAnimation =
ObjectAnimator.ofFloat(binding.newGameButton, View.ALPHA, 1.toFloat()).setDuration(700)
@ -468,7 +368,7 @@ class LoginActivity : BaseActivity() {
val scaleLogoAnimation =
ValueAnimator.ofInt(
binding.logoView.measuredHeight,
(binding.logoView.measuredHeight * 1.333333).toInt()
(binding.logoView.measuredHeight * 1.333333).toInt(),
)
scaleLogoAnimation.addUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as? Int
@ -487,7 +387,7 @@ class LoginActivity : BaseActivity() {
binding.showLoginButton.visibility = View.VISIBLE
binding.loginScrollview.visibility = View.INVISIBLE
}
}
},
)
val backAlphaAnimation =
ObjectAnimator.ofFloat(binding.backButton, View.ALPHA, 0.toFloat()).setDuration(800)
@ -496,7 +396,7 @@ class LoginActivity : BaseActivity() {
panAnimation,
scrollViewAlphaAnimation,
backAlphaAnimation,
scaleLogoAnimation
scaleLogoAnimation,
)
showAnimation.play(newGameAlphaAnimation).after(scrollViewAlphaAnimation)
showAnimation.play(showLoginAlphaAnimation).after(scrollViewAlphaAnimation)
@ -515,7 +415,7 @@ class LoginActivity : BaseActivity() {
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT
LinearLayout.LayoutParams.MATCH_PARENT,
)
input.layoutParams = lp
val alertDialog = HabiticaAlertDialog(this)
@ -543,14 +443,15 @@ class LoginActivity : BaseActivity() {
dismissKeyboard()
super.finish()
}
}
companion object {
fun show(v: View) {
v.visibility = View.VISIBLE
}
fun AuthenticationErrors.translatedMessage(context: Context): String {
return when (this) {
AuthenticationErrors.GET_CREDENTIALS_ERROR -> context.getString(R.string.auth_get_credentials_error)
AuthenticationErrors.INVALID_CREDENTIALS -> context.getString(R.string.auth_invalid_credentials)
fun hide(v: View) {
v.visibility = View.GONE
}
AuthenticationErrors.MISSING_FIELDS -> context.getString(R.string.login_validation_error_fieldsmissing)
AuthenticationErrors.PASSWORD_MISMATCH -> context.getString(R.string.password_not_matching)
AuthenticationErrors.PASSWORD_TOO_SHORT -> context.getString(R.string.password_too_short, minPasswordLength)
}
}

View file

@ -18,8 +18,6 @@ import dagger.hilt.android.AndroidEntryPoint
class StableFragment : BaseMainFragment<FragmentViewpagerBinding>() {
override var binding: FragmentViewpagerBinding? = null
private val viewModel: StableViewModel by viewModels()
override fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?

View file

@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.util.PatternsCompat
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.preference.EditTextPreference
import androidx.preference.Preference
@ -49,14 +50,11 @@ class AccountPreferenceFragment :
BasePreferencesFragment(),
SharedPreferences.OnSharedPreferenceChangeListener,
AccountUpdateConfirmed {
val viewModel: AuthenticationViewModel by viewModels()
@Inject
lateinit var hostConfig: HostConfig
@Inject
lateinit var apiClient: ApiClient
@Inject
lateinit var viewModel: AuthenticationViewModel
private lateinit var accountDialog: HabiticaAccountDialog
override var user: User? = null
@ -89,11 +87,11 @@ class AccountPreferenceFragment :
val user = user ?: return
configurePreference(
findPreference("username"),
user.authentication?.localAuthentication?.username
user.authentication?.localAuthentication?.username,
)
configurePreference(
findPreference("email"),
user.authentication?.localAuthentication?.email ?: getString(R.string.not_set)
user.authentication?.localAuthentication?.email ?: getString(R.string.not_set),
)
findPreference<Preference>("confirm_username")?.isVisible =
user.flags?.verifiedUsername != true
@ -147,7 +145,7 @@ class AccountPreferenceFragment :
private fun configurePreference(
preference: Preference?,
value: String?
value: String?,
) {
(preference as? EditTextPreference)?.let {
it.text = value
@ -189,14 +187,14 @@ class AccountPreferenceFragment :
updateUser(
"profile.name",
user?.profile?.name,
getString(R.string.display_name)
getString(R.string.display_name),
)
"photo_url" ->
updateUser(
"profile.imageUrl",
user?.profile?.imageUrl,
getString(R.string.photo_url)
getString(R.string.photo_url),
)
"about" -> updateUser("profile.blurb", user?.profile?.blurb, getString(R.string.about))
@ -205,7 +203,7 @@ class AccountPreferenceFragment :
disconnect("google", "Google")
} else {
activity?.let {
viewModel.handleGoogleLogin(it, pickAccountResult)
viewModel.startGoogleAuth(it)
}
}
}
@ -228,15 +226,14 @@ class AccountPreferenceFragment :
private fun disconnect(
network: String,
networkName: String
networkName: String,
) {
context?.let { context ->
val dialog = HabiticaAlertDialog(context)
dialog.setTitle(R.string.are_you_sure)
dialog.addButton(R.string.disconnect, true) { _, _ ->
lifecycleScope.launch {
apiClient.disconnectSocial(network)
userRepository.retrieveUser(true, true)
viewModel.removeSocialAuth(network)
displayDisconnectSuccess(networkName)
}
}
@ -245,52 +242,24 @@ class AccountPreferenceFragment :
}
}
private val pickAccountResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
viewModel.googleEmail = it?.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
activity?.let { it1 ->
viewModel.handleGoogleLoginResult(
it1,
recoverFromPlayServicesErrorResult
) { _ ->
displayAuthenticationSuccess(getString(R.string.google))
}
}
}
}
private fun displayAuthenticationSuccess(network: String) {
(activity as? SnackbarActivity)?.showSnackbar(
content = context?.getString(R.string.added_social_auth, network),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
}
private fun displayDisconnectSuccess(network: String) {
(activity as? SnackbarActivity)?.showSnackbar(
content = context?.getString(R.string.removed_social_auth, network),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
}
private val recoverFromPlayServicesErrorResult =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode != Activity.RESULT_CANCELED) {
activity?.let { it1 ->
viewModel.handleGoogleLoginResult(it1, null) { _ ->
displayAuthenticationSuccess(getString(R.string.google))
}
}
}
}
private fun updateUser(
path: String,
value: String?,
title: String
title: String,
) {
showSingleEntryDialog(value, title) {
if (value != it) {
@ -325,11 +294,11 @@ class AccountPreferenceFragment :
userRepository.updatePassword(
oldPasswordEditText?.text ?: "",
passwordEditText.text ?: "",
passwordRepeatEditText.text ?: ""
passwordRepeatEditText.text ?: "",
)
(activity as? SnackbarActivity)?.showSnackbar(
content = context.getString(R.string.password_changed),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
}
d.dismiss()
@ -371,15 +340,15 @@ class AccountPreferenceFragment :
val email =
if (showEmail) emailEditText?.text else user?.authentication?.findFirstSocialEmail()
lifecycleScope.launchCatching {
apiClient.registerUser(
viewModel.register(
user?.username ?: "",
email ?: "",
passwordEditText.text ?: "",
passwordRepeatEditText.text ?: ""
passwordRepeatEditText.text ?: "",
)
(activity as? SnackbarActivity)?.showSnackbar(
content = context.getString(R.string.password_added),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
}
dialog.dismiss()
@ -413,7 +382,7 @@ class AccountPreferenceFragment :
lifecycleScope.launchCatching {
userRepository.updateEmail(
emailEditText.text.toString(),
passwordEditText?.text.toString()
passwordEditText?.text.toString(),
)
lifecycleScope.launch(ExceptionHandler.coroutine()) {
userRepository.retrieveUser(true, true)
@ -448,7 +417,7 @@ class AccountPreferenceFragment :
value: String?,
title: String,
validator: ((String?) -> Boolean)? = null,
onChange: (String?) -> Unit
onChange: (String?) -> Unit,
) {
val inflater = context?.layoutInflater
val view = inflater?.inflate(R.layout.dialog_edittext, null)
@ -539,7 +508,7 @@ class AccountPreferenceFragment :
dialog.addButton(R.string.confirm, true) { _, _ ->
lifecycleScope.launchCatching {
userRepository.updateLoginName(
user?.authentication?.localAuthentication?.username ?: ""
user?.authentication?.localAuthentication?.username ?: "",
)
}
}
@ -558,7 +527,7 @@ class AccountPreferenceFragment :
private fun copyValue(
name: String,
value: CharSequence?
value: CharSequence?,
) {
val clipboard: ClipboardManager? =
context?.let { getSystemService(it, ClipboardManager::class.java) }
@ -566,14 +535,14 @@ class AccountPreferenceFragment :
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
(activity as? SnackbarActivity)?.showSnackbar(
content = context?.getString(R.string.copied_to_clipboard, name),
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS
displayType = HabiticaSnackbar.SnackbarDisplayType.SUCCESS,
)
}
}
override fun onSharedPreferenceChanged(
p0: SharedPreferences?,
p1: String?
p1: String?,
) {
}

View file

@ -1,160 +1,156 @@
package com.habitrpg.android.habitica.ui.viewmodels
import android.accounts.AccountManager
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.activity.result.ActivityResultLauncher
import android.util.Log
import androidx.core.content.edit
import com.google.android.gms.auth.GoogleAuthException
import com.google.android.gms.auth.GoogleAuthUtil
import com.google.android.gms.auth.GooglePlayServicesAvailabilityException
import com.google.android.gms.auth.UserRecoverableAuthException
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.GooglePlayServicesUtil
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.Scopes
import com.habitrpg.android.habitica.R
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import com.habitrpg.android.habitica.BuildConfig
import com.habitrpg.android.habitica.data.ApiClient
import com.habitrpg.android.habitica.data.UserRepository
import com.habitrpg.android.habitica.extensions.addCloseButton
import com.habitrpg.android.habitica.extensions.AuthenticationErrors
import com.habitrpg.android.habitica.helpers.Analytics
import com.habitrpg.android.habitica.helpers.AnalyticsTarget
import com.habitrpg.android.habitica.helpers.AppConfigManager
import com.habitrpg.android.habitica.helpers.EventCategory
import com.habitrpg.android.habitica.helpers.HitType
import com.habitrpg.android.habitica.modules.AuthenticationHandler
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
import com.habitrpg.common.habitica.api.HostConfig
import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.common.habitica.helpers.KeyHelper
import com.habitrpg.common.habitica.helpers.launchCatching
import com.habitrpg.common.habitica.models.auth.UserAuthResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
class AuthenticationViewModel
@Inject
constructor(
@HiltViewModel
class AuthenticationViewModel @Inject constructor(
val apiClient: ApiClient,
val userRepository: UserRepository,
val sharedPrefs: SharedPreferences,
val authenticationHandler: AuthenticationHandler,
val configManager: AppConfigManager,
val hostConfig: HostConfig,
private val keyHelper: KeyHelper?
) {
var googleEmail: String? = null
private val keyHelper: KeyHelper?,
) : ViewModel() {
private val _showAuthProgress = MutableStateFlow(false)
val showAuthProgress: Flow<Boolean> = _showAuthProgress
val isRegistering = MutableStateFlow(false)
private val _authenticationError: MutableSharedFlow<AuthenticationErrors?> = MutableSharedFlow()
val authenticationError: Flow<AuthenticationErrors?> = _authenticationError
.onEach { _showAuthProgress.value = false }
private val _authenticationSuccess = MutableStateFlow<Boolean?>(null)
val authenticationSuccess: Flow<Boolean?> = _authenticationSuccess
.onEach { _showAuthProgress.value = false }
fun handleGoogleLogin(
activity: Activity,
pickAccountResult: ActivityResultLauncher<Intent>
) {
if (!checkPlayServices(activity)) {
fun validateInputs(
username: String,
password: String,
email: String? = null,
confirmPassword: String? = null,
): AuthenticationErrors? {
if (username.isBlank() || password.isBlank()) {
return AuthenticationErrors.MISSING_FIELDS
}
if (isRegistering.value) {
if (email.isNullOrBlank()) {
return AuthenticationErrors.MISSING_FIELDS
}
if (password.length < configManager.minimumPasswordLength()) {
return AuthenticationErrors.PASSWORD_TOO_SHORT.apply {
minPasswordLength = configManager.minimumPasswordLength()
}
}
if (password != confirmPassword) {
return AuthenticationErrors.PASSWORD_MISMATCH
}
}
return null
}
fun login(username: String, password: String) {
_showAuthProgress.value = true
viewModelScope.launch {
try {
val response = apiClient.connectUser(username, password)
handleAuthResponse(response)
} catch (e: Exception) {
authenticationError()
Analytics.logException(e)
}
}
}
fun register(username: String, email: String, password: String, confirmPassword: String) {
_showAuthProgress.value = true
viewModelScope.launch {
try {
val response = apiClient.registerUser(username, email, password, confirmPassword)
handleAuthResponse(response)
} catch (e: Exception) {
authenticationError()
Analytics.logException(e)
}
}
}
suspend fun removeSocialAuth(network: String) {
apiClient.disconnectSocial(network)
userRepository.retrieveUser(true, true)
}
private fun authenticationError(error: AuthenticationErrors? = null) {
viewModelScope.launch { _authenticationError.emit(error) }
}
private fun handleAuthResponse(response: UserAuthResponse?) {
if (response == null) {
authenticationError()
return
}
val accountTypes = arrayOf("com.google")
val intent =
AccountManager.newChooseAccountIntent(
null,
null,
accountTypes,
true,
null,
null,
null,
null
)
try {
pickAccountResult.launch(intent)
} catch (e: ActivityNotFoundException) {
val alert = HabiticaAlertDialog(activity)
alert.setTitle(R.string.authentication_error_title)
alert.setMessage(R.string.google_services_missing)
alert.addCloseButton()
alert.show()
}
}
fun handleGoogleLoginResult(
activity: Activity,
recoverFromPlayServicesErrorResult: ActivityResultLauncher<Intent>?,
onSuccess: (Boolean) -> Unit
) {
val scopesString = Scopes.PROFILE + " " + Scopes.EMAIL
val scopes = "oauth2:$scopesString"
var newUser: Boolean
CoroutineScope(Dispatchers.IO).launchCatching({ throwable ->
if (recoverFromPlayServicesErrorResult == null) return@launchCatching
if (throwable is GoogleAuthException) {
handleGoogleAuthException(
throwable,
activity,
recoverFromPlayServicesErrorResult
)
}
}) {
val token = GoogleAuthUtil.getToken(activity, googleEmail ?: "", scopes)
val response =
apiClient.connectSocial("google", googleEmail ?: "", token) ?: return@launchCatching
newUser = response.newUser
handleAuthResponse(response)
onSuccess(newUser)
}
}
private fun handleGoogleAuthException(
e: Exception,
activity: Activity,
recoverFromPlayServicesErrorResult: ActivityResultLauncher<Intent>
) {
if (e is GooglePlayServicesAvailabilityException) {
GoogleApiAvailability.getInstance()
GooglePlayServicesUtil.showErrorDialogFragment(
e.connectionStatusCode,
activity,
null,
REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR
) {
}
return
} else if (e is UserRecoverableAuthException) {
// Unable to authenticate, such as when the user has not yet granted
// the app access to the account, but the user can fix this.
// Forward the user to an activity in Google Play services.
if (!activity.isFinishing) {
val intent = e.intent ?: return
recoverFromPlayServicesErrorResult.launch(intent)
}
}
}
private fun checkPlayServices(activity: Activity): Boolean {
val googleAPI = GoogleApiAvailability.getInstance()
val result = googleAPI.isGooglePlayServicesAvailable(activity)
if (result != ConnectionResult.SUCCESS) {
if (googleAPI.isUserResolvableError(result)) {
googleAPI.getErrorDialog(
activity,
result,
PLAY_SERVICES_RESOLUTION_REQUEST
)?.show()
}
return false
}
return true
}
fun handleAuthResponse(userAuthResponse: UserAuthResponse) {
try {
saveTokens(userAuthResponse.apiToken, userAuthResponse.id)
saveTokens(response.apiToken, response.id)
} catch (e: Exception) {
Analytics.logException(e)
}
if (isRegistering.value) {
Analytics.sendEvent("user_registered", EventCategory.BEHAVIOUR, HitType.EVENT, target = AnalyticsTarget.FIREBASE)
} else {
Analytics.sendEvent("login", EventCategory.BEHAVIOUR, HitType.EVENT)
}
viewModelScope.launch(ExceptionHandler.coroutine()) {
userRepository.retrieveUser(true, true)
_authenticationSuccess.value = isRegistering.value
}
}
@Throws(Exception::class)
private fun saveTokens(
api: String,
user: String
user: String,
) {
this.apiClient.updateAuthenticationCredentials(user, api)
authenticationHandler.updateUserID(user)
@ -173,14 +169,69 @@ constructor(
if ((encryptedKey?.length ?: 0) > 5) {
putString(user, encryptedKey)
} else {
// Something might have gone wrong with encryption, so fall back to this.
putString("APIToken", api)
}
}
}
companion object {
private const val REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR = 1001
private const val PLAY_SERVICES_RESOLUTION_REQUEST = 9000
fun startGoogleAuth(context: Context) {
val googleIdOption = GetSignInWithGoogleOption.Builder(BuildConfig.GOOGLE_AUTH_CLIENT_ID)
.build()
val request = GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()
viewModelScope.launch {
try {
val result = CredentialManager.create(context).getCredential(
request = request,
context = context,
)
handleSignIn(context, result)
} catch (e: GetCredentialException) {
authenticationError(AuthenticationErrors.GET_CREDENTIALS_ERROR)
Log.e("AuthenticationViewModel", "Get Credential Exception", e)
}
}
}
private suspend fun handleSignIn(context: Context, result: GetCredentialResponse) {
val credential = result.credential
when (credential) {
is CustomCredential -> {
if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
try {
val googleIdTokenCredential = GoogleIdTokenCredential
.createFrom(credential.data)
val authorizationRequest = AuthorizationRequest.Builder()
.requestOfflineAccess(BuildConfig.GOOGLE_AUTH_CLIENT_ID)
.setRequestedScopes(
listOf(
Scope(Scopes.PROFILE),
Scope(Scopes.EMAIL),
)
)
.build()
val result = Identity.getAuthorizationClient(context)
.authorize(authorizationRequest).await()
if (result != null && result.accessToken != null) {
val response = result.accessToken?.let { apiClient.connectSocial("google", googleIdTokenCredential.id, it) }
handleAuthResponse(response)
}
} catch (e: GoogleIdTokenParsingException) {
authenticationError(AuthenticationErrors.INVALID_CREDENTIALS)
Log.e("AuthenticationViewModel", "Received an invalid google id token response", e)
}
} else {
authenticationError(AuthenticationErrors.INVALID_CREDENTIALS)
Log.e("AuthenticationViewModel", "Unexpected type of credential")
}
}
else -> {
authenticationError(AuthenticationErrors.INVALID_CREDENTIALS)
Log.e("AuthenticationViewModel", "Unexpected type of credential")
}
}
}
}

View file

@ -52,12 +52,12 @@ fun HabiticaPullRefreshIndicator(
color = backgroundColor
) {
AnimatedVisibility(
visible = isRefreshing || state.progress > 0f,
visible = isRefreshing || state.distanceFraction > 0f,
enter = fadeIn(),
exit = fadeOut()
) {
HabiticaCircularProgressView(
partialDisplay = if (isRefreshing) 1f else state.progress,
partialDisplay = if (isRefreshing) 1f else state.distanceFraction,
animate = isRefreshing,
indicatorSize = 40.dp,
strokeWidth = 6.dp,