diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 85c804c4587f45fddc8bc08560ec1313af0a6687..6728a0d0dec9c2395d2f6c145fb1db95d91abec6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -135,3 +135,9 @@ native <methods>; ###JNA -keep class com.sun.jna.** { *; } -keep class * implements com.sun.jna.** { *; } + + ###Passkeys + -if class androidx.credentials.CredentialManager + -keep class androidx.credentials.playservices.** { + *; + } diff --git a/auth/src/main/java/org/futo/circles/auth/feature/log_in/switch_user/list/SwitchUsersViewHolder.kt b/auth/src/main/java/org/futo/circles/auth/feature/log_in/switch_user/list/SwitchUsersViewHolder.kt index 379ecc277af7f15b76e866af14640ed26ce23772..e0fa465620fbf075663a38cc6512059694cf3b59 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/log_in/switch_user/list/SwitchUsersViewHolder.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/log_in/switch_user/list/SwitchUsersViewHolder.kt @@ -3,11 +3,10 @@ package org.futo.circles.auth.feature.log_in.switch_user.list import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.futo.circles.auth.databinding.ListItemSwitchUserBinding -import org.futo.circles.core.extensions.notEmptyDisplayName import org.futo.circles.auth.model.SwitchUserListItem import org.futo.circles.core.base.list.ViewBindingHolder -import org.futo.circles.core.extensions.loadRoomProfileIcon import org.futo.circles.core.extensions.loadUserProfileIcon +import org.futo.circles.core.extensions.notEmptyDisplayName import org.futo.circles.core.extensions.onClick class SwitchUsersViewHolder( diff --git a/auth/src/main/java/org/futo/circles/auth/feature/profile/setup/SetupProfileFragment.kt b/auth/src/main/java/org/futo/circles/auth/feature/profile/setup/SetupProfileFragment.kt index 34742ea03d939410ffdb76a6b0ae2a20c5db027f..54c18053d01d1a4e5867026399bf91e71c7940da 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/profile/setup/SetupProfileFragment.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/profile/setup/SetupProfileFragment.kt @@ -11,13 +11,13 @@ import dagger.hilt.android.AndroidEntryPoint import org.futo.circles.auth.R import org.futo.circles.auth.databinding.FragmentSetupProfileBinding import org.futo.circles.core.base.NetworkObserver +import org.futo.circles.core.base.fragment.HasLoadingState import org.futo.circles.core.extensions.getText import org.futo.circles.core.extensions.navigateSafe import org.futo.circles.core.extensions.observeData import org.futo.circles.core.extensions.observeResponse import org.futo.circles.core.extensions.setEnabledViews import org.futo.circles.core.extensions.showDialog -import org.futo.circles.core.base.fragment.HasLoadingState import org.futo.circles.core.feature.picker.helper.MediaPickerHelper @AndroidEntryPoint @@ -58,7 +58,7 @@ class SetupProfileFragment : Fragment(R.layout.fragment_setup_profile), HasLoadi private fun setupObservers() { NetworkObserver.observe(this) { - setEnabledViews(it, listOf(binding.btnSkip)) + setEnabledViews(it, listOf(binding.btnSkip, binding.btnSave)) } viewModel.profileImageLiveData.observeData(this) { setSaveButtonEnabled() @@ -76,6 +76,6 @@ class SetupProfileFragment : Fragment(R.layout.fragment_setup_profile), HasLoadi private fun setSaveButtonEnabled() { binding.btnSave.isEnabled = viewModel.isProfileImageChosen() || - binding.tilDisplayName.editText?.text.isNullOrEmpty() != true + binding.tilDisplayName.getText().isNotEmpty() } } \ No newline at end of file diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADataSource.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADataSource.kt index 01a620e786cc656a7e39611d8fac2e55bf0cef0a..0f790b86e37c8b1316c1e62be12fea7a9b6ad8d0 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADataSource.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADataSource.kt @@ -95,6 +95,8 @@ abstract class UIADataSource { else -> LoginFlowTypes.TERMS } + fun getUserId() = "@${userName}:${domain}" + private fun isStageRetry(result: RegistrationResult?): Boolean { val nextStageType = ((result as? RegistrationResult.FlowResponse)?.flowResult?.missingStages?.firstOrNull() as? Stage.Other)?.type @@ -129,6 +131,7 @@ abstract class UIADataSource { SUBSCRPTION_GOOGLE_TYPE -> UIANavigationEvent.Subscription LOGIN_EMAIL_REQUEST_TOKEN_TYPE, ENROLL_EMAIL_REQUEST_TOKEN_TYPE -> UIANavigationEvent.ValidateEmail + LOGIN_EMAIL_SUBMIT_TOKEN_TYPE, ENROLL_EMAIL_SUBMIT_TOKEN_TYPE -> null //stay on same screen ENROLL_USERNAME_TYPE -> UIANavigationEvent.Username diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADialogFragment.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADialogFragment.kt index 046a9860c977b263ada3858e2c18f79e69590149..080e28f95a98490f93762fa1c85d1919c76af01c 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADialogFragment.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/UIADialogFragment.kt @@ -1,7 +1,6 @@ package org.futo.circles.auth.feature.uia import android.app.Dialog -import android.content.DialogInterface import android.net.Uri import android.os.Bundle import android.view.View @@ -70,11 +69,6 @@ class UIADialogFragment : setupObservers() } - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - UIADataSourceProvider.clear() - } - private fun setupViews() { binding.toolbar.apply { title = getString( diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/UIAViewModel.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/UIAViewModel.kt index 3616f1ff15be10a049afc5582c712535d5f17adc..98bf1c77393142e3c040e75bda21362801ffe33b 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/UIAViewModel.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/UIAViewModel.kt @@ -88,7 +88,7 @@ class UIAViewModel @Inject constructor( passPhraseLoadingLiveData.postValue(LoadingData(isLoading = false)) refreshTokenManager.scheduleTokenRefreshIfNeeded(session) handleKeysBackup() - BSSpekeClientProvider.clear() + clearProviders() } } @@ -99,7 +99,7 @@ class UIAViewModel @Inject constructor( MatrixSessionProvider.awaitForSessionStart(session) preferencesProvider.setShouldShowAllExplanations() createPassPhraseDataSource.createPassPhraseBackup() - BSSpekeClientProvider.clear() + clearProviders() } (result as? Response.Success)?.let { navigationLiveData.postValue(AuthUIAScreenNavigationEvent.ConfigureWorkspace) @@ -116,7 +116,7 @@ class UIAViewModel @Inject constructor( ) MatrixSessionProvider.awaitForSessionSync(session) createPassPhraseDataSource.replaceToNewKeyBackup() - BSSpekeClientProvider.clear() + clearProviders() } (result as? Response.Success)?.let { navigationLiveData.postValue(AuthUIAScreenNavigationEvent.Home) @@ -170,4 +170,9 @@ class UIAViewModel @Inject constructor( } } + private fun clearProviders() { + BSSpekeClientProvider.clear() + UIADataSourceProvider.clear() + } + } \ No newline at end of file diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/flow/LoginStagesDataSource.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/flow/LoginStagesDataSource.kt index b685d27820567599e3f9547e7ccc0515022c9bd6..2de99217e986ec806c71cdf9634bb6e0a79b8cc3 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/flow/LoginStagesDataSource.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/flow/LoginStagesDataSource.kt @@ -53,7 +53,7 @@ class LoginStagesDataSource @Inject constructor( } private fun getIdentifier() = mapOf( - USER_PARAM_KEY to "@$userName:$domain", + USER_PARAM_KEY to getUserId(), TYPE_PARAM_KEY to LOGIN_PASSWORD_USER_ID_TYPE ) } \ No newline at end of file diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordDataSource.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordDataSource.kt index 99ed81dedb96b8f6a0be9f9b326a8a6e3d4fc3cc..2e8adc523133b45450ddacd83695fc080a920d79 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordDataSource.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordDataSource.kt @@ -4,19 +4,18 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import org.futo.circles.auth.R import org.futo.circles.auth.feature.uia.UIADataSource.Companion.DIRECT_LOGIN_PASSWORD_TYPE -import org.futo.circles.auth.feature.uia.UIADataSource.Companion.LOGIN_BSSPEKE_OPRF_TYPE -import org.futo.circles.auth.feature.uia.UIADataSource.Companion.LOGIN_PASSWORD_TYPE import org.futo.circles.auth.feature.uia.UIADataSource.Companion.ENROLL_BSSPEKE_OPRF_TYPE import org.futo.circles.auth.feature.uia.UIADataSource.Companion.ENROLL_BSSPEKE_SAVE_TYPE import org.futo.circles.auth.feature.uia.UIADataSource.Companion.ENROLL_PASSWORD_TYPE +import org.futo.circles.auth.feature.uia.UIADataSource.Companion.LOGIN_BSSPEKE_OPRF_TYPE import org.futo.circles.auth.feature.uia.UIADataSource.Companion.LOGIN_BSSPEKE_VERIFY_TYPE +import org.futo.circles.auth.feature.uia.UIADataSource.Companion.LOGIN_PASSWORD_TYPE import org.futo.circles.auth.feature.uia.UIADataSource.Companion.TYPE_PARAM_KEY import org.futo.circles.auth.feature.uia.UIADataSourceProvider import org.futo.circles.core.extensions.Response import org.futo.circles.core.extensions.createResult import org.futo.circles.core.provider.MatrixInstanceProvider import org.matrix.android.sdk.api.auth.registration.RegistrationResult -import org.matrix.android.sdk.api.auth.registration.Stage import javax.inject.Inject class PasswordDataSource @Inject constructor( @@ -29,39 +28,32 @@ class PasswordDataSource @Inject constructor( suspend fun processPasswordStage(password: String): Response<Unit> = when (uiaDataSource.getCurrentStageKey()) { LOGIN_BSSPEKE_OPRF_TYPE, ENROLL_BSSPEKE_OPRF_TYPE, - LOGIN_BSSPEKE_VERIFY_TYPE, ENROLL_BSSPEKE_SAVE_TYPE-> + LOGIN_BSSPEKE_VERIFY_TYPE, ENROLL_BSSPEKE_SAVE_TYPE -> bsSpekeStageDataSource.processPasswordStage(password) - ENROLL_PASSWORD_TYPE -> processRegistrationPasswordStage(password) - LOGIN_PASSWORD_TYPE -> processPasswordStageL(password) + ENROLL_PASSWORD_TYPE -> processCirclesPasswordStage(password, false) + LOGIN_PASSWORD_TYPE -> processCirclesPasswordStage(password, true) DIRECT_LOGIN_PASSWORD_TYPE -> processDirectPasswordStage(password) else -> throw IllegalArgumentException("Unsupported password stage") } - private suspend fun processRegistrationPasswordStage(password: String): Response<Unit> = - when (val result = uiaDataSource.performUIAStage( + private suspend fun processCirclesPasswordStage( + password: String, + isLogin: Boolean + ): Response<Unit> { + val type = if (isLogin) LOGIN_PASSWORD_TYPE else ENROLL_PASSWORD_TYPE + val passwordKey = if (isLogin) LOGIN_PASSWORD_PARAM_KEY else REGISTRATION_PASSWORD_PARAM_KEY + + return when (val result = uiaDataSource.performUIAStage( mapOf( - TYPE_PARAM_KEY to ENROLL_PASSWORD_TYPE, - REGISTRATION_PASSWORD_PARAM_KEY to password + TYPE_PARAM_KEY to type, + passwordKey to password ) )) { is Response.Success -> Response.Success(Unit) is Response.Error -> result } - - - private suspend fun processPasswordStageL(password: String): Response<Unit> { - val result = uiaDataSource.performUIAStage( - mapOf( - TYPE_PARAM_KEY to LOGIN_PASSWORD_TYPE, - LOGIN_PASSWORD_PARAM_KEY to password - ), password - ) - return when (result) { - is Response.Success -> Response.Success(Unit) - is Response.Error -> result - } } private suspend fun processDirectPasswordStage(password: String): Response<Unit> { diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordFragment.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordFragment.kt index a021c6bfc587ef3088a1e67dda08751d16e8f24b..ed079c87c71e8321742ca5cf76afce8d6cc20ce9 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordFragment.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordFragment.kt @@ -16,6 +16,7 @@ import org.futo.circles.auth.feature.uia.stages.password.confirmation.SetupPassw import org.futo.circles.core.base.fragment.HasLoadingState import org.futo.circles.core.base.fragment.ParentBackPressOwnerFragment import org.futo.circles.core.extensions.getText +import org.futo.circles.core.extensions.observeData import org.futo.circles.core.extensions.observeResponse import org.futo.circles.core.extensions.setIsVisible import org.futo.circles.core.extensions.showError @@ -33,6 +34,7 @@ class PasswordFragment : ParentBackPressOwnerFragment(R.layout.fragment_password super.onViewCreated(view, savedInstanceState) setupViews() setupObservers() + if (!isSignupMode()) viewModel.getCredentials(requireContext()) } override fun onResume() { @@ -47,7 +49,11 @@ class PasswordFragment : ParentBackPressOwnerFragment(R.layout.fragment_password setText(getString(if (isSignupMode()) R.string.set_password else R.string.log_in)) setOnClickListener { startLoading(btnLogin) - viewModel.loginWithPassword(tilPassword.getText()) + viewModel.processPasswordStage( + tilPassword.getText(), + isSignupMode(), + requireContext() + ) } } tilPassword.editText?.apply { @@ -73,14 +79,18 @@ class PasswordFragment : ParentBackPressOwnerFragment(R.layout.fragment_password private fun setupObservers() { viewModel.passwordResponseLiveData.observeResponse(this, error = { showError(getString(R.string.invalid_password)) }) + viewModel.passwordSelectedEventLiveData.observeData(this) { + startLoading(binding.btnLogin) + binding.etPassword.setText(it) + } } private fun onPasswordDataChanged() { val password = binding.tilPassword.getText() val repeat = binding.tilRepeatPassword.getText() binding.btnLogin.isEnabled = if (isSignupMode()) { - binding.vPasswordStrength.isPasswordStrong() && password == repeat && password.isNotEmpty() - } else password.isNotEmpty() + binding.vPasswordStrength.isPasswordStrong() && password == repeat && password.isNotEmpty() && !binding.btnLogin.isLoading + } else password.isNotEmpty() && !binding.btnLogin.isLoading } private fun showPasswordWarningIfNeeded() { diff --git a/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordViewModel.kt b/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordViewModel.kt index 6db8e20ddddcf50156835b352fa6126f7d994435..f656588a51bee56cd1e5f46f97b3560018050a4a 100644 --- a/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordViewModel.kt +++ b/auth/src/main/java/org/futo/circles/auth/feature/uia/stages/password/PasswordViewModel.kt @@ -1,10 +1,18 @@ package org.futo.circles.auth.feature.uia.stages.password +import android.content.Context +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPasswordOption +import androidx.credentials.PasswordCredential import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import org.futo.circles.auth.feature.uia.UIADataSourceProvider import org.futo.circles.core.base.SingleEventLiveData import org.futo.circles.core.extensions.Response import org.futo.circles.core.extensions.launchBg +import org.matrix.android.sdk.api.extensions.tryOrNull import javax.inject.Inject @HiltViewModel @@ -14,10 +22,56 @@ class PasswordViewModel @Inject constructor( private var isPasswordWarningConfirmed: Boolean = false val passwordResponseLiveData = SingleEventLiveData<Response<Unit>>() + val passwordSelectedEventLiveData = SingleEventLiveData<String>() - fun loginWithPassword(password: String) { + fun processPasswordStage(password: String, isSignup: Boolean, activityContext: Context) { + launchBg { handlePasswordRequest(password, isSignup, activityContext) } + } + + fun getCredentials(activityContext: Context) { launchBg { - passwordResponseLiveData.postValue(passwordDataSource.processPasswordStage(password)) + tryOrNull { + val credentialManager = CredentialManager.create(activityContext) + val userId = UIADataSourceProvider.getDataSourceOrThrow().getUserId() + val request = GetCredentialRequest( + listOf(GetPasswordOption(allowedUserIds = setOf(userId))) + ) + + val result = credentialManager.getCredential( + context = activityContext, + request = request + ).credential + + if (result is PasswordCredential) { + val password = result.password + passwordSelectedEventLiveData.postValue(password) + handlePasswordRequest(password, false, activityContext) + } + } + } + } + + private suspend fun handlePasswordRequest( + password: String, + isSignup: Boolean, + activityContext: Context + ) { + if (isSignup) registerPassword(activityContext, password) + val result = passwordDataSource.processPasswordStage(password) + passwordResponseLiveData.postValue(result) + } + + private suspend fun registerPassword(activityContext: Context, password: String) { + tryOrNull { + val uiaDataSource = UIADataSourceProvider.getDataSourceOrThrow() + val createPasswordRequest = CreatePasswordRequest( + id = uiaDataSource.getUserId(), + password = password + ) + CredentialManager.create(activityContext).createCredential( + activityContext, + createPasswordRequest + ) } } diff --git a/auth/src/main/res/layout/fragment_password.xml b/auth/src/main/res/layout/fragment_password.xml index 638421e673d22110d6e485e78ab318ca07b82877..4b3e6e6c86df1d96539097f0999cb3b673d2177c 100644 --- a/auth/src/main/res/layout/fragment_password.xml +++ b/auth/src/main/res/layout/fragment_password.xml @@ -61,6 +61,7 @@ android:autofillHints="password" android:imeOptions="actionDone" android:inputType="textPassword" + android:isCredential="true" android:padding="8dp" /> <org.futo.circles.auth.view.PasswordStrengthView diff --git a/auth/src/main/res/layout/list_item_switch_user.xml b/auth/src/main/res/layout/list_item_switch_user.xml index 4be83338335d664ab1f95ec111a255e39c18e064..8b199acf53e825a4e0cc58063956d8d840dc2694 100644 --- a/auth/src/main/res/layout/list_item_switch_user.xml +++ b/auth/src/main/res/layout/list_item_switch_user.xml @@ -16,10 +16,9 @@ <com.google.android.material.imageview.ShapeableImageView android:id="@+id/ivRemove" - android:layout_width="24dp" + android:layout_width="wrap_content" android:layout_height="0dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" + android:padding="6dp" android:background="?attr/selectableItemBackgroundBorderless" android:clickable="true" android:focusable="true" diff --git a/core/build.gradle b/core/build.gradle index 0847aa87598b3631bc84606b763ff472b1c205ab..34f8fe3147890e72b45008783d97fb5f1cebe62e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -111,9 +111,12 @@ dependencies { api "io.noties.markwon:linkify:$markwon_version" api "io.noties.markwon:ext-strikethrough:$markwon_version" api "io.noties.markwon:ext-tasklist:$markwon_version" - api 'io.element.android:wysiwyg:2.33.0' + //Passkeys + api "androidx.credentials:credentials:1.3.0-alpha01" + api "androidx.credentials:credentials-play-services-auth:1.3.0-alpha01" + //Shake detection implementation 'com.squareup:seismic:1.0.3' diff --git a/core/src/main/java/org/futo/circles/core/view/LoadingButton.kt b/core/src/main/java/org/futo/circles/core/view/LoadingButton.kt index 1ceec3f8b0900856dc269d62ec3933a6bd0cacad..0a9be135547415c17959df5da1231ef2a7231245 100644 --- a/core/src/main/java/org/futo/circles/core/view/LoadingButton.kt +++ b/core/src/main/java/org/futo/circles/core/view/LoadingButton.kt @@ -7,7 +7,6 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.doOnLayout import org.futo.circles.core.R import org.futo.circles.core.databinding.ViewLoadingButtonBinding import org.futo.circles.core.extensions.getAttributes @@ -31,7 +30,8 @@ class LoadingButton( ViewLoadingButtonBinding.inflate(LayoutInflater.from(context), this) private var buttonText: String = "" - private var isLoading: Boolean = false + var isLoading: Boolean = false + private set init { getAttributes(attrs, R.styleable.LoadingButton) { @@ -60,7 +60,7 @@ class LoadingButton( override fun onRestoreInstanceState(state: Parcelable?) { val loadingButtonState = state as? LoadingButtonState super.onRestoreInstanceState(loadingButtonState?.superSavedState ?: state) - doOnLayout { + post { setText(loadingButtonState?.text ?: "") setIsLoading(loadingButtonState?.isLoading ?: false) isEnabled = loadingButtonState?.isEnabled ?: true