improve login

This commit is contained in:
Konrad Pozniak 2020-09-21 16:38:22 +02:00
parent bc8ced0fd2
commit 31794af1fd
18 changed files with 209 additions and 118 deletions

View File

@ -26,8 +26,8 @@
</activity>
<activity android:name=".components.login.LoginWebViewActivity">
<activity android:name=".components.login.LoginWebViewActivity"
android:label="@string/title_login">
</activity>
<activity
android:name=".components.settings.SettingsActivity"

View File

@ -20,6 +20,7 @@
package at.connyduck.pixelcat.components.login
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
@ -29,23 +30,30 @@ import androidx.activity.viewModels
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import at.connyduck.pixelcat.components.main.MainActivity
import at.connyduck.pixelcat.R
import at.connyduck.pixelcat.components.about.AboutActivity
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.components.settings.SettingsActivity
import at.connyduck.pixelcat.components.util.extension.visible
import at.connyduck.pixelcat.dagger.ViewModelFactory
import at.connyduck.pixelcat.databinding.ActivityLoginBinding
import at.connyduck.pixelcat.util.viewBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginActivity : BaseActivity(), Observer<LoginModel> {
@FlowPreview
@ExperimentalCoroutinesApi
class LoginActivity : BaseActivity() {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val loginViewModel: LoginViewModel by viewModels { viewModelFactory }
private val viewModel: LoginViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(ActivityLoginBinding::inflate)
@ -54,7 +62,7 @@ class LoginActivity : BaseActivity(), Observer<LoginModel> {
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.loginContainer) { _, insets ->
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val top = insets.getInsets(systemBars()).top
val toolbarParams = binding.loginToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
@ -67,10 +75,14 @@ class LoginActivity : BaseActivity(), Observer<LoginModel> {
setDisplayShowTitleEnabled(false)
}
loginViewModel.loginState.observe(this, this)
lifecycleScope.launch {
viewModel.observe().collect { loginModel ->
onChanged(loginModel)
}
}
binding.loginButton.setOnClickListener {
loginViewModel.startLogin(binding.loginInput.text.toString())
viewModel.startLogin(binding.loginInput.text.toString())
}
}
@ -78,13 +90,21 @@ class LoginActivity : BaseActivity(), Observer<LoginModel> {
val authCode = data?.getStringExtra(LoginWebViewActivity.RESULT_AUTHORIZATION_CODE)
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK && !authCode.isNullOrEmpty()) {
loginViewModel.authCode(authCode)
viewModel.authCode(authCode)
return
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onStart() {
super.onStart()
if (!intent.hasExtra(LoginWebViewActivity.RESULT_AUTHORIZATION_CODE)) {
viewModel.removeError()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.login, menu)
return super.onCreateOptionsMenu(menu)
@ -105,7 +125,8 @@ class LoginActivity : BaseActivity(), Observer<LoginModel> {
return super.onOptionsItemSelected(item)
}
override fun onChanged(loginModel: LoginModel?) {
private fun onChanged(loginModel: LoginModel?) {
binding.loginInput.setText(loginModel?.input)
if (loginModel == null) {
@ -113,23 +134,47 @@ class LoginActivity : BaseActivity(), Observer<LoginModel> {
}
when (loginModel.state) {
LoginState.NO_ERROR -> binding.loginInputLayout.error = null
LoginState.AUTH_ERROR -> binding.loginInputLayout.error = "auth error"
LoginState.INVALID_DOMAIN -> binding.loginInputLayout.error = "invalid domain"
LoginState.NETWORK_ERROR -> binding.loginInputLayout.error = "network error"
LoginState.NO_ERROR -> {
binding.loginInputLayout.error = null
setLoading(false)
}
LoginState.AUTH_ERROR -> {
binding.loginInputLayout.error = "auth error"
setLoading(false)
}
LoginState.INVALID_DOMAIN -> {
binding.loginInputLayout.error = "invalid domain"
setLoading(false)
}
LoginState.NETWORK_ERROR -> {
binding.loginInputLayout.error = "network error"
setLoading(false)
}
LoginState.LOADING -> {
setLoading(true)
}
LoginState.SUCCESS -> {
setLoading(true)
startActivityForResult(LoginWebViewActivity.newIntent(loginModel.domain!!, loginModel.clientId!!, loginModel.clientSecret!!, this), REQUEST_CODE)
}
LoginState.SUCCESS_FINAL -> {
startActivity(Intent(this, MainActivity::class.java)) // TODO dont create intent here
setLoading(true)
startActivity(MainActivity.newIntent(this))
finish()
}
}
}
private fun setLoading(loading: Boolean) {
binding.loginLoading.visible = loading
binding.loginImageView.visible = !loading
binding.loginInputLayout.visible = !loading
binding.loginButton.visible = !loading
}
companion object {
private const val REQUEST_CODE = 14
fun newIntent(context: Context) = Intent(context, LoginActivity::class.java)
}
}

View File

@ -32,5 +32,11 @@ data class LoginModel(
) : Parcelable
enum class LoginState { // TODO rename this stuff so it makes sense
LOADING, NO_ERROR, NETWORK_ERROR, INVALID_DOMAIN, AUTH_ERROR, SUCCESS, SUCCESS_FINAL
LOADING,
NO_ERROR,
NETWORK_ERROR,
INVALID_DOMAIN,
AUTH_ERROR,
SUCCESS,
SUCCESS_FINAL
}

View File

@ -19,52 +19,54 @@
package at.connyduck.pixelcat.components.login
import androidx.annotation.MainThread
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.pixelcat.config.Config
import at.connyduck.pixelcat.db.AccountManager
import at.connyduck.pixelcat.db.entitity.AccountAuthData
import at.connyduck.pixelcat.network.FediverseApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import java.util.Locale
import javax.inject.Inject
@FlowPreview
@ExperimentalCoroutinesApi
class LoginViewModel @Inject constructor(
private val fediverseApi: FediverseApi,
private val accountManager: AccountManager
) : ViewModel() {
val loginState = MutableLiveData<LoginModel>().apply {
value = LoginModel(state = LoginState.NO_ERROR)
}
private val loginState = ConflatedBroadcastChannel(LoginModel(state = LoginState.NO_ERROR))
fun observe() = loginState.asFlow()
@MainThread
fun startLogin(input: String) {
val domainInput = canonicalizeDomain(input)
try {
HttpUrl.Builder().host(domainInput).scheme("https").build()
} catch (e: IllegalArgumentException) {
loginState.value = LoginModel(input, LoginState.INVALID_DOMAIN)
return
}
val exceptionMatch = Config.domainExceptions.any { exception ->
domainInput.equals(exception, true) || domainInput.endsWith(".$exception", true)
}
if (exceptionMatch) {
loginState.value = LoginModel(input, LoginState.AUTH_ERROR)
return
}
loginState.value = LoginModel(input, LoginState.LOADING)
viewModelScope.launch {
val domainInput = canonicalizeDomain(input)
try {
HttpUrl.Builder().host(domainInput).scheme("https").build()
} catch (e: IllegalArgumentException) {
loginState.send(LoginModel(input, LoginState.INVALID_DOMAIN))
return@launch
}
val exceptionMatch = Config.domainExceptions.any { exception ->
domainInput.equals(exception, true) || domainInput.endsWith(".$exception", true)
}
if (exceptionMatch) {
loginState.send(LoginModel(input, LoginState.AUTH_ERROR))
return@launch
}
loginState.send(LoginModel(input, LoginState.LOADING))
fediverseApi.authenticateAppAsync(
domain = domainInput,
clientName = "Pixelcat",
@ -73,19 +75,18 @@ class LoginViewModel @Inject constructor(
scopes = Config.oAuthScopes
).fold(
{ appData ->
loginState.postValue(LoginModel(input, LoginState.SUCCESS, domainInput, appData.clientId, appData.clientSecret))
loginState.send(LoginModel(input, LoginState.SUCCESS, domainInput, appData.clientId, appData.clientSecret))
},
{
loginState.postValue(LoginModel(input, LoginState.AUTH_ERROR))
loginState.send(LoginModel(input, LoginState.AUTH_ERROR))
}
)
}
}
@MainThread
fun authCode(authCode: String) {
viewModelScope.launch {
val loginModel = loginState.value!!
val loginModel = loginState.value
fediverseApi.fetchOAuthToken(
domain = loginModel.domain!!,
@ -106,14 +107,21 @@ class LoginViewModel @Inject constructor(
clientSecret = loginModel.clientSecret
)
accountManager.addAccount(loginModel.domain, authData)
loginState.postValue(loginState.value?.copy(state = LoginState.SUCCESS_FINAL))
loginState.send(loginState.value.copy(state = LoginState.SUCCESS_FINAL))
},
{
loginState.send(loginState.value.copy(state = LoginState.AUTH_ERROR))
}
)
}
}
fun removeError() {
viewModelScope.launch {
loginState.send(loginState.value.copy(state = LoginState.NO_ERROR))
}
}
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")

View File

@ -23,23 +23,35 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import at.connyduck.pixelcat.config.Config
import android.webkit.WebViewClient
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import at.connyduck.pixelcat.components.general.BaseActivity
import at.connyduck.pixelcat.databinding.ActivityLoginWebViewBinding
class LoginWebViewActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginWebViewBinding
class LoginWebViewActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginWebViewBinding.inflate(layoutInflater)
val binding = ActivityLoginWebViewBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
val top = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
val toolbarParams = binding.loginToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
WindowInsetsCompat.CONSUMED
}
binding.loginToolbar.setNavigationOnClickListener {
onBackPressed()
}
val domain = intent.getStringExtra(EXTRA_DOMAIN)!!
val clientId = intent.getStringExtra(EXTRA_CLIENT_ID)!!
val clientSecret = intent.getStringExtra(EXTRA_CLIENT_SECRET)!!

View File

@ -20,6 +20,7 @@
package at.connyduck.pixelcat.components.main
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
@ -115,4 +116,8 @@ class MainActivity : BaseActivity() {
startActivity(ComposeActivity.newIntent(this, returnValue?.firstOrNull()!!))
}
}
companion object {
fun newIntent(context: Context) = Intent(context, MainActivity::class.java)
}
}

View File

@ -50,5 +50,4 @@ class NotificationsViewModel @Inject constructor(
).flow
}
.cachedIn(viewModelScope)
}

View File

@ -19,7 +19,6 @@
package at.connyduck.pixelcat.components.splash
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import at.connyduck.pixelcat.components.login.LoginActivity
@ -40,12 +39,9 @@ class SplashActivity : DaggerAppCompatActivity() {
lifecycleScope.launch {
val intent = if (accountManager.activeAccount() != null) {
Intent(
this@SplashActivity,
MainActivity::class.java
) // TODO don't create intents here
MainActivity.newIntent(this@SplashActivity)
} else {
Intent(this@SplashActivity, LoginActivity::class.java)
LoginActivity.newIntent(this@SplashActivity)
}
startActivity(intent)
finish()

View File

@ -28,5 +28,5 @@ object Config {
const val oAuthRedirect = "$oAuthScheme://$oAuthHost"
const val oAuthScopes = "read write follow"
val domainExceptions = arrayOf("gab.com", "gab.ai", "gabfed.com")
val domainExceptions = arrayOf("gab.com", "gab.ai", "spinster.xyz")
}

View File

@ -25,6 +25,7 @@ import at.connyduck.pixelcat.components.about.licenses.LicenseActivity
import at.connyduck.pixelcat.components.compose.ComposeActivity
import at.connyduck.pixelcat.components.timeline.detail.DetailActivity
import at.connyduck.pixelcat.components.login.LoginActivity
import at.connyduck.pixelcat.components.login.LoginWebViewActivity
import at.connyduck.pixelcat.components.profile.ProfileActivity
import at.connyduck.pixelcat.components.settings.SettingsActivity
import at.connyduck.pixelcat.components.splash.SplashActivity
@ -61,4 +62,7 @@ abstract class ActivityModule {
@ContributesAndroidInjector
abstract fun contributesDetailActivity(): DetailActivity
@ContributesAndroidInjector
abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity
}

View File

@ -36,7 +36,7 @@ fun Account.toEntity(accountId: Long) = TimelineAccountEntity(
id = id,
localUsername = localUsername,
username = username,
displayName = displayName,
displayName = name,
url = url,
avatar = avatar
)

View File

@ -19,14 +19,8 @@
package at.connyduck.pixelcat.model
import android.os.Parcel
import android.os.Parcelable
import android.text.Spanned
import androidx.core.text.HtmlCompat
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.android.parcel.Parceler
import kotlinx.android.parcel.Parcelize
import java.util.Date
@JsonClass(generateAdapter = true)
@ -55,38 +49,25 @@ data class Account(
get() = if (displayName.isEmpty()) {
localUsername
} else displayName
fun isRemote(): Boolean = this.username != this.localUsername
}
@JsonClass(generateAdapter = true)
@Parcelize
data class AccountSource(
// val privacy: Status.Visibility,
val privacy: Status.Visibility,
val sensitive: Boolean,
val note: String,
val fields: List<StringField>?
) : Parcelable
)
@JsonClass(generateAdapter = true)
@Parcelize
data class Field(
val name: String,
// val value: @WriteWith<SpannedParceler>() Spanned,
val value: String,
@Json(name = "verified_at") val verifiedAt: Date?
) : Parcelable
)
@JsonClass(generateAdapter = true)
@Parcelize
data class StringField(
val name: String,
val value: String
) : Parcelable
object SpannedParceler : Parceler<Spanned> {
override fun create(parcel: Parcel): Spanned = HtmlCompat.fromHtml(parcel.readString() ?: "", HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_PARAGRAPH)
override fun Spanned.write(parcel: Parcel, flags: Int) {
parcel.writeString(HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
}
}
)

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="false" android:color="#6BFFFFFF"/>
<item android:state_selected="true" android:color="#fff"/>
</selector>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/loginContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/pixelcat_gradient">
@ -18,6 +17,7 @@
android:id="@+id/loginToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<ImageView
@ -36,21 +36,27 @@
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:hint="@string/instance_input_hint"
app:boxStrokeColor="@color/edit_text_color_white"
app:boxStrokeErrorColor="@color/white"
app:errorIconTint="@color/white"
app:errorTextColor="@color/white"
app:hintTextColor="@color/white"
app:layout_constraintBottom_toTopOf="@id/loginButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:hint="@string/instance_input_hint"
app:layout_constraintTop_toBottomOf="@+id/loginImageView">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#fff"
android:ems="10"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
android:inputType="textUri"
android:textColor="@color/white"
android:textCursorDrawable="@null" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/loginButton"
@ -69,19 +75,19 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loginInputLayout" />
<!--ProgressBar
android:id="@+id/loading"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginTop="64dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="64dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/password"
app:layout_constraintStart_toStartOf="@+id/password"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.3"/-->
<ProgressBar
android:id="@+id/loginLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="32dp"
android:layout_marginTop="64dp"
android:layout_marginEnd="32dp"
android:layout_marginBottom="64dp"
android:indeterminateTint="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,15 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/pixelcat_gradient">
<androidx.appcompat.widget.Toolbar
android:id="@+id/loginToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:navigationIcon="@drawable/ic_arrow_back"
app:title="@string/title_login"
app:titleTextAppearance="@style/TextAppearanceToolbar"
app:titleTextColor="#fff" />
</com.google.android.material.appbar.AppBarLayout>
<WebView
android:id="@+id/loginWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".components.login.LoginWebViewActivity">
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
<WebView android:id="@+id/loginWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -13,4 +13,12 @@
</style>
<style name="AppTheme.Fullscreen">
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:navigationBarColor">@color/transparent</item>
<item name="android:navigationBarDividerColor">@color/transparent</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>

View File

@ -87,4 +87,6 @@
<string name="notification_favourited">%1$s liked your post</string>
<string name="notification_followed">%1$s followed you</string>
<string name="title_login">Login</string>
</resources>

View File

@ -27,7 +27,7 @@
</style>
<style name="AppTheme.Fullscreen">
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
<style name="TextAppearanceToolbar" parent="@style/TextAppearance.MaterialComponents.Headline6">