diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SsoIdentityProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SsoIdentityProvider.kt index cfaf74ce24..64b3e180aa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SsoIdentityProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SsoIdentityProvider.kt @@ -48,7 +48,7 @@ data class SsoIdentityProvider( */ @Json(name = "brand") val brand: String? -) : Parcelable { +) : Parcelable, Comparable { companion object { const val BRAND_GOOGLE = "org.matrix.google" @@ -58,4 +58,25 @@ data class SsoIdentityProvider( const val BRAND_TWITTER = "org.matrix.twitter" const val BRAND_GITLAB = "org.matrix.gitlab" } + + override fun compareTo(other: SsoIdentityProvider): Int { + return other.toPriority().compareTo(toPriority()) + } + + private fun toPriority(): Int { + return when (brand) { + // We are on Android, so user is more likely to have a Google account + BRAND_GOOGLE -> 5 + // Facebook is also an important SSO provider + BRAND_FACEBOOK -> 4 + // Twitter is more for professionals + BRAND_TWITTER -> 3 + // Here it's very for techie people + BRAND_GITHUB, + BRAND_GITLAB -> 2 + // And finally, if the account has been created with an iPhone... + BRAND_APPLE -> 1 + else -> 0 + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginProfileInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginProfileInfo.kt new file mode 100644 index 0000000000..288a6d1232 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginProfileInfo.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.auth.login + +data class LoginProfileInfo( + val matrixId: String, + val displayName: String?, + val fullAvatarUrl: String? +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt index da6eb0c3ac..a2a9373837 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt @@ -24,6 +24,11 @@ import org.matrix.android.sdk.api.session.Session * More documentation can be found in the file https://github.com/vector-im/element-android/blob/main/docs/signin.md */ interface LoginWizard { + /** + * Get some information about a matrixId: displayName and avatar url + */ + suspend fun getProfileInfo(matrixId: String): LoginProfileInfo + /** * Login to the homeserver. * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt index f93f285c6e..5a9fa9edf6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.auth import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.internal.auth.data.Availability import org.matrix.android.sdk.internal.auth.data.LoginFlowResponse import org.matrix.android.sdk.internal.auth.data.PasswordLoginParams @@ -73,6 +74,15 @@ internal interface AuthAPI { @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "register/available") suspend fun registerAvailable(@Query("username") username: String): Availability + /** + * Get the combined profile information for this user. + * This API may be used to fetch the user's own profile information or other users; either locally or on remote homeservers. + * This API may return keys which are not limited to displayname or avatar_url. + * @param userId the user id to fetch profile info + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}") + suspend fun getProfile(@Path("userId") userId: String): JsonDict + /** * Add 3Pid during registration * Ref: https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928 diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt index 8b81f42e03..854caf8a62 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.auth.login import android.util.Patterns +import org.matrix.android.sdk.api.auth.login.LoginProfileInfo import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.session.Session @@ -30,6 +31,7 @@ import org.matrix.android.sdk.internal.auth.db.PendingSessionData import org.matrix.android.sdk.internal.auth.registration.AddThreePidRegistrationParams import org.matrix.android.sdk.internal.auth.registration.RegisterAddThreePidTask import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.session.content.DefaultContentUrlResolver internal class DefaultLoginWizard( private val authAPI: AuthAPI, @@ -39,6 +41,15 @@ internal class DefaultLoginWizard( private var pendingSessionData: PendingSessionData = pendingSessionStore.getPendingSessionData() ?: error("Pending session data should exist here") + private val getProfileTask: GetProfileTask = DefaultGetProfileTask( + authAPI, + DefaultContentUrlResolver(pendingSessionData.homeServerConnectionConfig) + ) + + override suspend fun getProfileInfo(matrixId: String): LoginProfileInfo { + return getProfileTask.execute(GetProfileTask.Params(matrixId)) + } + override suspend fun login(login: String, password: String, deviceName: String): Session { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/GetProfileTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/GetProfileTask.kt new file mode 100644 index 0000000000..bb9faf49c4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/GetProfileTask.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.auth.login + +import org.matrix.android.sdk.api.auth.login.LoginProfileInfo +import org.matrix.android.sdk.api.session.content.ContentUrlResolver +import org.matrix.android.sdk.api.session.profile.ProfileService +import org.matrix.android.sdk.internal.auth.AuthAPI +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.task.Task + +internal interface GetProfileTask : Task { + data class Params( + val userId: String + ) +} + +internal class DefaultGetProfileTask( + private val authAPI: AuthAPI, + private val contentUrlResolver: ContentUrlResolver +) : GetProfileTask { + + override suspend fun execute(params: GetProfileTask.Params): LoginProfileInfo { + val info = executeRequest(null) { + authAPI.getProfile(params.userId) + } + + return LoginProfileInfo( + matrixId = params.userId, + displayName = info[ProfileService.DISPLAY_NAME_KEY] as? String, + fullAvatarUrl = contentUrlResolver.resolveFullSize(info[ProfileService.AVATAR_URL_KEY] as? String) + ) + } +} diff --git a/vector/build.gradle b/vector/build.gradle index 0411e24808..aa962fd422 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -137,6 +137,11 @@ android { buildConfigField "String", "BUILD_NUMBER", "\"${buildNumber}\"" resValue "string", "build_number", "\"${buildNumber}\"" + // The two booleans must not have the same value. We need two values for the manifest + // LoginFlowV2 is disabled to be merged on develop (changelog: Improve login/register flow (#1410, #2585, #3172)) + resValue "bool", "useLoginV1", "true" + resValue "bool", "useLoginV2", "false" + buildConfigField "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy", "outboundSessionKeySharingStrategy", "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy.WhenTyping" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index ea4ea9fd12..f601dc88dd 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -106,6 +106,24 @@ + + + + + + + + + + + diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 25c8a07597..8580543022 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -28,11 +28,11 @@ import im.vector.app.features.crypto.keysbackup.settings.KeysBackupSettingsFragm import im.vector.app.features.crypto.quads.SharedSecuredStorageKeyFragment import im.vector.app.features.crypto.quads.SharedSecuredStoragePassphraseFragment import im.vector.app.features.crypto.quads.SharedSecuredStorageResetAllFragment -import im.vector.app.features.crypto.recover.BootstrapReAuthFragment import im.vector.app.features.crypto.recover.BootstrapConclusionFragment import im.vector.app.features.crypto.recover.BootstrapConfirmPassphraseFragment import im.vector.app.features.crypto.recover.BootstrapEnterPassphraseFragment import im.vector.app.features.crypto.recover.BootstrapMigrateBackupFragment +import im.vector.app.features.crypto.recover.BootstrapReAuthFragment import im.vector.app.features.crypto.recover.BootstrapSaveRecoveryKeyFragment import im.vector.app.features.crypto.recover.BootstrapSetupRecoveryKeyFragment import im.vector.app.features.crypto.recover.BootstrapWaitingFragment @@ -71,6 +71,24 @@ import im.vector.app.features.login.LoginSplashFragment import im.vector.app.features.login.LoginWaitForEmailFragment import im.vector.app.features.login.LoginWebFragment import im.vector.app.features.login.terms.LoginTermsFragment +import im.vector.app.features.login2.LoginCaptchaFragment2 +import im.vector.app.features.login2.LoginFragmentSigninPassword2 +import im.vector.app.features.login2.LoginFragmentSigninUsername2 +import im.vector.app.features.login2.LoginFragmentSignupPassword2 +import im.vector.app.features.login2.LoginFragmentSignupUsername2 +import im.vector.app.features.login2.created.AccountCreatedFragment +import im.vector.app.features.login2.LoginFragmentToAny2 +import im.vector.app.features.login2.LoginGenericTextInputFormFragment2 +import im.vector.app.features.login2.LoginResetPasswordFragment2 +import im.vector.app.features.login2.LoginResetPasswordMailConfirmationFragment2 +import im.vector.app.features.login2.LoginResetPasswordSuccessFragment2 +import im.vector.app.features.login2.LoginServerSelectionFragment2 +import im.vector.app.features.login2.LoginServerUrlFormFragment2 +import im.vector.app.features.login2.LoginSplashSignUpSignInSelectionFragment2 +import im.vector.app.features.login2.LoginSsoOnlyFragment2 +import im.vector.app.features.login2.LoginWaitForEmailFragment2 +import im.vector.app.features.login2.LoginWebFragment2 +import im.vector.app.features.login2.terms.LoginTermsFragment2 import im.vector.app.features.matrixto.MatrixToRoomSpaceFragment import im.vector.app.features.matrixto.MatrixToUserFragment import im.vector.app.features.pin.PinFragment @@ -85,11 +103,11 @@ import im.vector.app.features.roommemberprofile.RoomMemberProfileFragment import im.vector.app.features.roommemberprofile.devices.DeviceListFragment import im.vector.app.features.roommemberprofile.devices.DeviceTrustInfoActionFragment import im.vector.app.features.roomprofile.RoomProfileFragment +import im.vector.app.features.roomprofile.alias.RoomAliasFragment import im.vector.app.features.roomprofile.banned.RoomBannedMemberListFragment import im.vector.app.features.roomprofile.members.RoomMemberListFragment -import im.vector.app.features.roomprofile.settings.RoomSettingsFragment -import im.vector.app.features.roomprofile.alias.RoomAliasFragment import im.vector.app.features.roomprofile.permissions.RoomPermissionsFragment +import im.vector.app.features.roomprofile.settings.RoomSettingsFragment import im.vector.app.features.roomprofile.uploads.RoomUploadsFragment import im.vector.app.features.roomprofile.uploads.files.RoomUploadsFilesFragment import im.vector.app.features.roomprofile.uploads.media.RoomUploadsMediaFragment @@ -267,6 +285,96 @@ interface FragmentModule { @FragmentKey(LoginWaitForEmailFragment::class) fun bindLoginWaitForEmailFragment(fragment: LoginWaitForEmailFragment): Fragment + @Binds + @IntoMap + @FragmentKey(LoginFragmentSigninUsername2::class) + fun bindLoginFragmentSigninUsername2(fragment: LoginFragmentSigninUsername2): Fragment + + @Binds + @IntoMap + @FragmentKey(AccountCreatedFragment::class) + fun bindAccountCreatedFragment(fragment: AccountCreatedFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginFragmentSignupUsername2::class) + fun bindLoginFragmentSignupUsername2(fragment: LoginFragmentSignupUsername2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginFragmentSigninPassword2::class) + fun bindLoginFragmentSigninPassword2(fragment: LoginFragmentSigninPassword2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginFragmentSignupPassword2::class) + fun bindLoginFragmentSignupPassword2(fragment: LoginFragmentSignupPassword2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginCaptchaFragment2::class) + fun bindLoginCaptchaFragment2(fragment: LoginCaptchaFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginFragmentToAny2::class) + fun bindLoginFragmentToAny2(fragment: LoginFragmentToAny2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginTermsFragment2::class) + fun bindLoginTermsFragment2(fragment: LoginTermsFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginServerUrlFormFragment2::class) + fun bindLoginServerUrlFormFragment2(fragment: LoginServerUrlFormFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginResetPasswordMailConfirmationFragment2::class) + fun bindLoginResetPasswordMailConfirmationFragment2(fragment: LoginResetPasswordMailConfirmationFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginResetPasswordFragment2::class) + fun bindLoginResetPasswordFragment2(fragment: LoginResetPasswordFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginResetPasswordSuccessFragment2::class) + fun bindLoginResetPasswordSuccessFragment2(fragment: LoginResetPasswordSuccessFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginServerSelectionFragment2::class) + fun bindLoginServerSelectionFragment2(fragment: LoginServerSelectionFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginSsoOnlyFragment2::class) + fun bindLoginSsoOnlyFragment2(fragment: LoginSsoOnlyFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginSplashSignUpSignInSelectionFragment2::class) + fun bindLoginSplashSignUpSignInSelectionFragment2(fragment: LoginSplashSignUpSignInSelectionFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginWebFragment2::class) + fun bindLoginWebFragment2(fragment: LoginWebFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginGenericTextInputFormFragment2::class) + fun bindLoginGenericTextInputFormFragment2(fragment: LoginGenericTextInputFormFragment2): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginWaitForEmailFragment2::class) + fun bindLoginWaitForEmailFragment2(fragment: LoginWaitForEmailFragment2): Fragment + @Binds @IntoMap @FragmentKey(UserListFragment::class) diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index dbfbcbdd1e..9dbfa8fe30 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -53,6 +53,7 @@ import im.vector.app.features.invite.InviteUsersToRoomActivity import im.vector.app.features.invite.VectorInviteView import im.vector.app.features.link.LinkHandlerActivity import im.vector.app.features.login.LoginActivity +import im.vector.app.features.login2.LoginActivity2 import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.media.BigImageViewerActivity import im.vector.app.features.media.VectorAttachmentViewerActivity @@ -133,6 +134,7 @@ interface ScreenComponent { fun inject(activity: KeysBackupManageActivity) fun inject(activity: EmojiReactionPickerActivity) fun inject(activity: LoginActivity) + fun inject(activity: LoginActivity2) fun inject(activity: LinkHandlerActivity) fun inject(activity: MainActivity) fun inject(activity: RoomDirectoryActivity) diff --git a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt index 5b36e4e628..55ec8b605e 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt @@ -94,6 +94,12 @@ fun AppCompatActivity.addFragmentToBackstack( } } +fun AppCompatActivity.resetBackstack() { + repeat(supportFragmentManager.backStackEntryCount) { + supportFragmentManager.popBackStack() + } +} + fun AppCompatActivity.hideKeyboard() { currentFocus?.hideKeyboard() } diff --git a/vector/src/main/java/im/vector/app/core/extensions/MvRxExtension.kt b/vector/src/main/java/im/vector/app/core/extensions/MvRxExtension.kt new file mode 100644 index 0000000000..9daf16a589 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/extensions/MvRxExtension.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.extensions + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Success + +/** + * It maybe already exist somewhere but I cannot find it + */ +suspend fun tryAsync(block: suspend () -> T): Async { + return try { + Success(block.invoke()) + } catch (failure: Throwable) { + Fail(failure) + } +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt index 574e25a5ee..6b3902deea 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt @@ -62,7 +62,7 @@ fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int, @StringRes coloredTextRes: Int, @AttrRes colorAttribute: Int = R.attr.colorAccent, underline: Boolean = false, - onClick: (() -> Unit)?) { + onClick: (() -> Unit)? = null) { val coloredPart = resources.getString(coloredTextRes) // Insert colored part into the full text val fullText = resources.getString(fullTextRes, coloredPart) diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt index 34e73c8702..054b1bcff1 100644 --- a/vector/src/main/java/im/vector/app/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt @@ -34,7 +34,6 @@ import im.vector.app.core.utils.deleteAllFiles import im.vector.app.databinding.ActivityMainBinding import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.ShortcutsHandler -import im.vector.app.features.login.LoginActivity import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.PinLocker @@ -43,6 +42,7 @@ import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.signout.hard.SignedOutActivity import im.vector.app.features.signout.soft.SoftLogoutActivity +import im.vector.app.features.signout.soft.SoftLogoutActivity2 import im.vector.app.features.themes.ActivityOtherThemes import im.vector.app.features.ui.UiStateRepository import kotlinx.parcelize.Parcelize @@ -222,12 +222,14 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity val intent = when { args.clearCredentials && !ignoreClearCredentials - && (!args.isUserLoggedOut || args.isAccountDeactivated) -> + && (!args.isUserLoggedOut || args.isAccountDeactivated) -> { // User has explicitly asked to log out or deactivated his account - LoginActivity.newIntent(this, null) + navigator.openLogin(this, null) + null + } args.isSoftLogout -> // The homeserver has invalidated the token, with a soft logout - SoftLogoutActivity.newIntent(this) + getSoftLogoutActivityIntent() args.isUserLoggedOut -> // the homeserver has invalidated the token (password changed, device deleted, other security reasons) SignedOutActivity.newIntent(this) @@ -238,13 +240,23 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity HomeActivity.newIntent(this) } else { // The token is still invalid - SoftLogoutActivity.newIntent(this) + getSoftLogoutActivityIntent() } - else -> + else -> { // First start, or no active session - LoginActivity.newIntent(this, null) + navigator.openLogin(this, null) + null + } } - startActivity(intent) + intent?.let { startActivity(it) } finish() } + + private fun getSoftLogoutActivityIntent(): Intent { + return if (resources.getBoolean(R.bool.useLoginV2)) { + SoftLogoutActivity2.newIntent(this) + } else { + SoftLogoutActivity.newIntent(this) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt index 23ca5eee9c..65bc5e1200 100644 --- a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt @@ -41,6 +41,7 @@ import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.ColorFilterTransformation +import org.matrix.android.sdk.api.auth.login.LoginProfileInfo import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.util.MatrixItem @@ -113,6 +114,23 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active .into(imageView) } + @UiThread + fun render(profileInfo: LoginProfileInfo, imageView: ImageView) { + // Create a Fake MatrixItem, for the placeholder + val matrixItem = MatrixItem.UserItem( + // Need an id starting with @ + id = profileInfo.matrixId, + displayName = profileInfo.displayName + ) + + val placeholder = getPlaceholderDrawable(matrixItem) + GlideApp.with(imageView) + .load(profileInfo.fullAvatarUrl) + .apply(RequestOptions.circleCropTransform()) + .placeholder(placeholder) + .into(imageView) + } + @UiThread fun render(glideRequests: GlideRequests, matrixItem: MatrixItem, diff --git a/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt b/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt index 6c0e142b38..570de63437 100644 --- a/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt @@ -27,7 +27,6 @@ import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.toast import im.vector.app.databinding.ActivityProgressBinding -import im.vector.app.features.login.LoginActivity import im.vector.app.features.login.LoginConfig import im.vector.app.features.permalink.PermalinkHandler import io.reactivex.android.schedulers.AndroidSchedulers @@ -126,9 +125,11 @@ class LinkHandlerActivity : VectorBaseActivity() { * Start the login screen with identity server and home server pre-filled */ private fun startLoginActivity(uri: Uri) { - val intent = LoginActivity.newIntent(this, LoginConfig.parse(uri)) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + navigator.openLogin( + context = this, + loginConfig = LoginConfig.parse(uri), + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + ) finish() } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt index 956cf1615d..bafe836a8d 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt @@ -25,7 +25,6 @@ import androidx.core.view.isVisible import im.vector.app.BuildConfig import im.vector.app.databinding.FragmentLoginSplashBinding import im.vector.app.features.settings.VectorPreferences - import javax.inject.Inject /** diff --git a/vector/src/main/java/im/vector/app/features/login2/AbstractLoginFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/AbstractLoginFragment2.kt new file mode 100644 index 0000000000..39bee00ac2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/AbstractLoginFragment2.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Bundle +import android.view.View +import androidx.annotation.CallSuper +import androidx.appcompat.app.AlertDialog +import androidx.transition.TransitionInflater +import androidx.viewbinding.ViewBinding +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.dialogs.UnrecognizedCertificateDialog +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.OnBackPressed +import im.vector.app.core.platform.VectorBaseFragment +import kotlinx.coroutines.CancellationException +import org.matrix.android.sdk.api.failure.Failure + +/** + * Parent Fragment for all the login/registration screens + */ +abstract class AbstractLoginFragment2 : VectorBaseFragment(), OnBackPressed { + + protected val loginViewModel: LoginViewModel2 by activityViewModel() + + private var isResetPasswordStarted = false + + // Due to async, we keep a boolean to avoid displaying twice the cancellation dialog + private var displayCancelDialog = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move) + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + loginViewModel.observeViewEvents { + handleLoginViewEvents(it) + } + } + + private fun handleLoginViewEvents(loginViewEvents: LoginViewEvents2) { + when (loginViewEvents) { + is LoginViewEvents2.Failure -> showFailure(loginViewEvents.throwable) + else -> + // This is handled by the Activity + Unit + }.exhaustive + } + + override fun showFailure(throwable: Throwable) { + // Only the resumed Fragment can eventually show the error, to avoid multiple dialog display + if (!isResumed) { + return + } + + when (throwable) { + is CancellationException -> + /* Ignore this error, user has cancelled the action */ + Unit + is Failure.UnrecognizedCertificateFailure -> + showUnrecognizedCertificateFailure(throwable) + else -> + onError(throwable) + } + } + + private fun showUnrecognizedCertificateFailure(failure: Failure.UnrecognizedCertificateFailure) { + // Ask the user to accept the certificate + unrecognizedCertificateDialog.show(requireActivity(), + failure.fingerprint, + failure.url, + object : UnrecognizedCertificateDialog.Callback { + override fun onAccept() { + // User accept the certificate + loginViewModel.handle(LoginAction2.UserAcceptCertificate(failure.fingerprint)) + } + + override fun onIgnore() { + // Cannot happen in this case + } + + override fun onReject() { + // Nothing to do in this case + } + }) + } + + open fun onError(throwable: Throwable) { + super.showFailure(throwable) + } + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + return when { + displayCancelDialog && loginViewModel.isRegistrationStarted -> { + // Ask for confirmation before cancelling the registration + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_signup_cancel_confirmation_title) + .setMessage(R.string.login_signup_cancel_confirmation_content) + .setPositiveButton(R.string.yes) { _, _ -> + displayCancelDialog = false + vectorBaseActivity.onBackPressed() + } + .setNegativeButton(R.string.no, null) + .show() + + true + } + displayCancelDialog && isResetPasswordStarted -> { + // Ask for confirmation before cancelling the reset password + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_reset_password_cancel_confirmation_title) + .setMessage(R.string.login_reset_password_cancel_confirmation_content) + .setPositiveButton(R.string.yes) { _, _ -> + displayCancelDialog = false + vectorBaseActivity.onBackPressed() + } + .setNegativeButton(R.string.no, null) + .show() + + true + } + else -> { + resetViewModel() + // Do not consume the Back event + false + } + } + } + + final override fun invalidate() = withState(loginViewModel) { state -> + // True when email is sent with success to the homeserver + isResetPasswordStarted = state.resetPasswordEmail.isNullOrBlank().not() + + updateWithState(state) + } + + open fun updateWithState(state: LoginViewState2) { + // No op by default + } + + // Reset any modification on the loginViewModel by the current fragment + abstract fun resetViewModel() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt new file mode 100644 index 0000000000..b12d9638dc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.content.ComponentName +import android.net.Uri +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import androidx.viewbinding.ViewBinding +import com.airbnb.mvrx.withState +import im.vector.app.core.utils.openUrlInChromeCustomTab +import im.vector.app.features.login.hasSso +import im.vector.app.features.login.ssoIdentityProviders + +abstract class AbstractSSOLoginFragment2 : AbstractLoginFragment2() { + + // For sso + private var customTabsServiceConnection: CustomTabsServiceConnection? = null + private var customTabsClient: CustomTabsClient? = null + private var customTabsSession: CustomTabsSession? = null + + override fun onStart() { + super.onStart() + val hasSSO = withState(loginViewModel) { it.loginMode.hasSso() } + if (hasSSO) { + val packageName = CustomTabsClient.getPackageName(requireContext(), null) + + // packageName can be null if there are 0 or several CustomTabs compatible browsers installed on the device + if (packageName != null) { + customTabsServiceConnection = object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { + customTabsClient = client + .also { it.warmup(0L) } + prefetchIfNeeded() + } + + override fun onServiceDisconnected(name: ComponentName?) { + } + } + .also { + CustomTabsClient.bindCustomTabsService( + requireContext(), + // Despite the API, packageName cannot be null + packageName, + it + ) + } + } + } + } + + override fun onStop() { + super.onStop() + val hasSSO = withState(loginViewModel) { it.loginMode.hasSso() } + if (hasSSO) { + customTabsServiceConnection?.let { requireContext().unbindService(it) } + customTabsServiceConnection = null + } + } + + private fun prefetchUrl(url: String) { + if (customTabsSession == null) { + customTabsSession = customTabsClient?.newSession(null) + } + + customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null) + } + + protected fun openInCustomTab(ssoUrl: String) { + openUrlInChromeCustomTab(requireContext(), customTabsSession, ssoUrl) + } + + private fun prefetchIfNeeded() { + withState(loginViewModel) { state -> + if (state.loginMode.hasSso() && state.loginMode.ssoIdentityProviders().isNullOrEmpty()) { + // in this case we can prefetch (not other cases for privacy concerns) + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null + ) + ?.let { prefetchUrl(it) } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt new file mode 100644 index 0000000000..a9df217e3c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.features.login.LoginConfig +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider +import org.matrix.android.sdk.api.auth.registration.RegisterThreePid +import org.matrix.android.sdk.internal.network.ssl.Fingerprint + +sealed class LoginAction2 : VectorViewModelAction { + // First action + data class UpdateSignMode(val signMode: SignMode2) : LoginAction2() + + // Signin, but user wants to choose a server + object ChooseAServerForSignin : LoginAction2() + + object EnterServerUrl : LoginAction2() + object ChooseDefaultHomeServer : LoginAction2() + data class UpdateHomeServer(val homeServerUrl: String) : LoginAction2() + data class LoginWithToken(val loginToken: String) : LoginAction2() + data class WebLoginSuccess(val credentials: Credentials) : LoginAction2() + data class InitWith(val loginConfig: LoginConfig?) : LoginAction2() + data class ResetPassword(val email: String, val newPassword: String) : LoginAction2() + object ResetPasswordMailConfirmed : LoginAction2() + + // Username to Login or Register, depending on the signMode + data class SetUserName(val username: String) : LoginAction2() + + // Password to Login or Register, depending on the signMode + data class SetUserPassword(val password: String) : LoginAction2() + + // When user has selected a homeserver + data class LoginWith(val login: String, val password: String) : LoginAction2() + + // Register actions + open class RegisterAction : LoginAction2() + + data class AddThreePid(val threePid: RegisterThreePid) : RegisterAction() + object SendAgainThreePid : RegisterAction() + + // TODO Confirm Email (from link in the email, open in the phone, intercepted by RiotX) + data class ValidateThreePid(val code: String) : RegisterAction() + + data class CheckIfEmailHasBeenValidated(val delayMillis: Long) : RegisterAction() + object StopEmailValidationCheck : RegisterAction() + + data class CaptchaDone(val captchaResponse: String) : RegisterAction() + object AcceptTerms : RegisterAction() + object RegisterDummy : RegisterAction() + + // Reset actions + open class ResetAction : LoginAction2() + + object ResetHomeServerUrl : ResetAction() + object ResetSignMode : ResetAction() + object ResetSignin : ResetAction() + object ResetSignup : ResetAction() + object ResetResetPassword : ResetAction() + + // Homeserver history + object ClearHomeServerHistory : LoginAction2() + + // For the soft logout case + data class SetupSsoForSessionRecovery(val homeServerUrl: String, + val deviceId: String, + val ssoIdentityProviders: List?) : LoginAction2() + + data class PostViewEvent(val viewEvent: LoginViewEvents2) : LoginAction2() + + data class UserAcceptCertificate(val fingerprint: Fingerprint) : LoginAction2() + + // Account customization is over + object Finish : LoginAction2() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt new file mode 100644 index 0000000000..f5663c1f5a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt @@ -0,0 +1,410 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.content.Context +import android.content.Intent +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import com.airbnb.mvrx.viewModel +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.POP_BACK_STACK_EXCLUSIVE +import im.vector.app.core.extensions.addFragment +import im.vector.app.core.extensions.addFragmentToBackstack +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.resetBackstack +import im.vector.app.core.platform.ToolbarConfigurable +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivityLoginBinding +import im.vector.app.features.home.HomeActivity +import im.vector.app.features.login.LoginCaptchaFragmentArgument +import im.vector.app.features.login.LoginConfig +import im.vector.app.features.login.LoginGenericTextInputFormFragmentArgument +import im.vector.app.features.login.LoginWaitForEmailFragmentArgument +import im.vector.app.features.login.TextInputFormFragmentMode +import im.vector.app.features.login.isSupported +import im.vector.app.features.login.terms.LoginTermsFragmentArgument +import im.vector.app.features.login.terms.toLocalizedLoginTerms +import im.vector.app.features.login2.created.AccountCreatedFragment +import im.vector.app.features.login2.terms.LoginTermsFragment2 +import im.vector.app.features.pin.UnlockedActivity + +import org.matrix.android.sdk.api.auth.registration.FlowResult +import org.matrix.android.sdk.api.auth.registration.Stage +import org.matrix.android.sdk.api.extensions.tryOrNull +import javax.inject.Inject + +/** + * The LoginActivity manages the fragment navigation and also display the loading View + */ +open class LoginActivity2 : VectorBaseActivity(), ToolbarConfigurable, UnlockedActivity { + + private val loginViewModel: LoginViewModel2 by viewModel() + + @Inject lateinit var loginViewModelFactory: LoginViewModel2.Factory + + @CallSuper + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + private val enterAnim = R.anim.enter_fade_in + private val exitAnim = R.anim.exit_fade_out + + private val popEnterAnim = R.anim.no_anim + private val popExitAnim = R.anim.exit_fade_out + + private val topFragment: Fragment? + get() = supportFragmentManager.findFragmentById(R.id.loginFragmentContainer) + + private val commonOption: (FragmentTransaction) -> Unit = { ft -> + // Find the loginLogo on the current Fragment, this should not return null + (topFragment?.view as? ViewGroup) + // Find findViewById does not work, I do not know why + // findViewById(R.id.loginLogo) + ?.children + ?.firstOrNull { it.id == R.id.loginLogo } + ?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + } + + final override fun getBinding() = ActivityLoginBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun initUiAndData() { + if (isFirstCreation()) { + addFirstFragment() + } + + loginViewModel + .subscribe(this) { + updateWithState(it) + } + + loginViewModel.observeViewEvents { handleLoginViewEvents(it) } + + // Get config extra + val loginConfig = intent.getParcelableExtra(EXTRA_CONFIG) + if (isFirstCreation()) { + // TODO Check this + loginViewModel.handle(LoginAction2.InitWith(loginConfig)) + } + } + + protected open fun addFirstFragment() { + addFragment(R.id.loginFragmentContainer, LoginSplashSignUpSignInSelectionFragment2::class.java) + } + + private fun handleLoginViewEvents(event: LoginViewEvents2) { + when (event) { + is LoginViewEvents2.RegistrationFlowResult -> { + // Check that all flows are supported by the application + if (event.flowResult.missingStages.any { !it.isSupported() }) { + // Display a popup to propose use web fallback + onRegistrationStageNotSupported() + } else { + if (event.isRegistrationStarted) { + // Go on with registration flow + handleRegistrationNavigation(event.flowResult) + } else { + /* + // First ask for login and password + // I add a tag to indicate that this fragment is a registration stage. + // This way it will be automatically popped in when starting the next registration stage + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment2::class.java, + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption + ) + + */ + } + } + } + is LoginViewEvents2.OutdatedHomeserver -> { + AlertDialog.Builder(this) + .setTitle(R.string.login_error_outdated_homeserver_title) + .setMessage(R.string.login_error_outdated_homeserver_warning_content) + .setPositiveButton(R.string.ok, null) + .show() + Unit + } + is LoginViewEvents2.OpenServerSelection -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginServerSelectionFragment2::class.java, + option = { ft -> + findViewById(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // Disable transition of text + // findViewById(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // No transition here now actually + // findViewById(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // TODO Disabled because it provokes a flickering + // ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + }) + is LoginViewEvents2.OpenHomeServerUrlFormScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginServerUrlFormFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OpenSignInEnterIdentifierScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragmentSigninUsername2::class.java, + option = { ft -> + findViewById(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // Disable transition of text + // findViewById(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // No transition here now actually + // findViewById(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // TODO Disabled because it provokes a flickering + // ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + }) + } + is LoginViewEvents2.OpenSsoOnlyScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginSsoOnlyFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnWebLoginError -> onWebLoginError(event) + is LoginViewEvents2.OpenResetPasswordScreen -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordFragment2::class.java, + option = commonOption) + is LoginViewEvents2.OnResetPasswordSendThreePidDone -> { + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordMailConfirmationFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnResetPasswordMailConfirmationSuccess -> { + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordSuccessFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnResetPasswordMailConfirmationSuccessDone -> { + // Go back to the login fragment + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + } + is LoginViewEvents2.OnSendEmailSuccess -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWaitForEmailFragment2::class.java, + LoginWaitForEmailFragmentArgument(event.email), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is LoginViewEvents2.OpenSigninPasswordScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragmentSigninPassword2::class.java, + tag = FRAGMENT_LOGIN_TAG, + option = commonOption) + } + is LoginViewEvents2.OpenSignupPasswordScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragmentSignupPassword2::class.java, + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + } + is LoginViewEvents2.OpenSignUpChooseUsernameScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragmentSignupUsername2::class.java, + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + } + is LoginViewEvents2.OpenSignInWithAnythingScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragmentToAny2::class.java, + tag = FRAGMENT_LOGIN_TAG, + option = commonOption) + } + is LoginViewEvents2.OnSendMsisdnSuccess -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment2::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, event.msisdn), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is LoginViewEvents2.Failure -> + // This is handled by the Fragments + Unit + is LoginViewEvents2.OnLoginModeNotSupported -> + onLoginModeNotSupported(event.supportedTypes) + is LoginViewEvents2.OnSessionCreated -> handleOnSessionCreated(event) + is LoginViewEvents2.Finish -> terminate(true) + is LoginViewEvents2.CancelRegistration -> handleCancelRegistration() + }.exhaustive + } + + private fun handleCancelRegistration() { + // Cleanup the back stack + resetBackstack() + } + + private fun handleOnSessionCreated(event: LoginViewEvents2.OnSessionCreated) { + if (event.newAccount) { + // Propose to set avatar and display name + // Back on this Fragment will finish the Activity + addFragmentToBackstack(R.id.loginFragmentContainer, + AccountCreatedFragment::class.java, + option = commonOption) + } else { + terminate(false) + } + } + + private fun terminate(newAccount: Boolean) { + val intent = HomeActivity.newIntent( + this, + accountCreation = newAccount + ) + startActivity(intent) + finish() + } + + private fun updateWithState(LoginViewState2: LoginViewState2) { + // Loading + setIsLoading(LoginViewState2.isLoading) + } + + // Hack for AccountCreatedFragment + fun setIsLoading(isLoading: Boolean) { + views.loginLoading.isVisible = isLoading + } + + private fun onWebLoginError(onWebLoginError: LoginViewEvents2.OnWebLoginError) { + // Pop the backstack + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + // And inform the user + AlertDialog.Builder(this) + .setTitle(R.string.dialog_title_error) + .setMessage(getString(R.string.login_sso_error_message, onWebLoginError.description, onWebLoginError.errorCode)) + .setPositiveButton(R.string.ok, null) + .show() + } + + /** + * Handle the SSO redirection here + */ + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + intent?.data + ?.let { tryOrNull { it.getQueryParameter("loginToken") } } + ?.let { loginViewModel.handle(LoginAction2.LoginWithToken(it)) } + } + + private fun onRegistrationStageNotSupported() { + AlertDialog.Builder(this) + .setTitle(R.string.app_name) + .setMessage(getString(R.string.login_registration_not_supported)) + .setPositiveButton(R.string.yes) { _, _ -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment2::class.java, + option = commonOption) + } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun onLoginModeNotSupported(supportedTypes: List) { + AlertDialog.Builder(this) + .setTitle(R.string.app_name) + .setMessage(getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" })) + .setPositiveButton(R.string.yes) { _, _ -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment2::class.java, + option = commonOption) + } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun handleRegistrationNavigation(flowResult: FlowResult) { + // Complete all mandatory stages first + val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory } + + if (mandatoryStage != null) { + doStage(mandatoryStage) + } else { + // Consider optional stages + val optionalStage = flowResult.missingStages.firstOrNull { !it.mandatory && it !is Stage.Dummy } + if (optionalStage == null) { + // Should not happen... + } else { + doStage(optionalStage) + } + } + } + + private fun doStage(stage: Stage) { + // Ensure there is no fragment for registration stage in the backstack + supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + when (stage) { + is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginCaptchaFragment2::class.java, + LoginCaptchaFragmentArgument(stage.publicKey), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Email -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment2::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Msisdn -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment2::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Terms -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginTermsFragment2::class.java, + LoginTermsFragmentArgument(stage.policies.toLocalizedLoginTerms(getString(R.string.resources_language))), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + else -> Unit // Should not happen + } + } + + override fun configure(toolbar: Toolbar) { + configureToolbar(toolbar) + } + + companion object { + private const val FRAGMENT_REGISTRATION_STAGE_TAG = "FRAGMENT_REGISTRATION_STAGE_TAG" + private const val FRAGMENT_LOGIN_TAG = "FRAGMENT_LOGIN_TAG" + + private const val EXTRA_CONFIG = "EXTRA_CONFIG" + + // Note that the domain can be displayed to the user for confirmation that he trusts it. So use a human readable string + const val VECTOR_REDIRECT_URL = "element://connect" + + fun newIntent(context: Context, loginConfig: LoginConfig?): Intent { + return Intent(context, LoginActivity2::class.java).apply { + putExtra(EXTRA_CONFIG, loginConfig) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt new file mode 100644 index 0000000000..682fc4089e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Build +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import im.vector.app.R +import im.vector.app.core.utils.AssetReader +import im.vector.app.databinding.FragmentLoginCaptchaBinding +import im.vector.app.features.login.JavascriptResponse +import im.vector.app.features.login.LoginCaptchaFragmentArgument +import org.matrix.android.sdk.internal.di.MoshiProvider +import timber.log.Timber +import java.net.URLDecoder +import java.util.Formatter +import javax.inject.Inject + +/** + * In this screen, the user is asked to confirm he is not a robot + */ +class LoginCaptchaFragment2 @Inject constructor( + private val assetReader: AssetReader +) : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginCaptchaBinding { + return FragmentLoginCaptchaBinding.inflate(inflater, container, false) + } + + private val params: LoginCaptchaFragmentArgument by args() + + private var isWebViewLoaded = false + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: LoginViewState2) { + views.loginCaptchaWevView.settings.javaScriptEnabled = true + + val reCaptchaPage = assetReader.readAssetFile("reCaptchaPage.html") ?: error("missing asset reCaptchaPage.html") + + val html = Formatter().format(reCaptchaPage, params.siteKey).toString() + val mime = "text/html" + val encoding = "utf-8" + + val homeServerUrl = state.homeServerUrl ?: error("missing url of homeserver") + views.loginCaptchaWevView.loadDataWithBaseURL(homeServerUrl, html, mime, encoding, null) + views.loginCaptchaWevView.requestLayout() + + views.loginCaptchaWevView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + if (!isAdded) { + return + } + + // Show loader + views.loginCaptchaProgress.isVisible = true + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + + if (!isAdded) { + return + } + + // Hide loader + views.loginCaptchaProgress.isVisible = false + } + + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { + Timber.d("## onReceivedSslError() : ${error.certificate}") + + if (!isAdded) { + return + } + + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.ssl_could_not_verify) + .setPositiveButton(R.string.ssl_trust) { _, _ -> + Timber.d("## onReceivedSslError() : the user trusted") + handler.proceed() + } + .setNegativeButton(R.string.ssl_do_not_trust) { _, _ -> + Timber.d("## onReceivedSslError() : the user did not trust") + handler.cancel() + } + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + handler.cancel() + Timber.d("## onReceivedSslError() : the user dismisses the trust dialog.") + dialog.dismiss() + return@OnKeyListener true + } + false + }) + .setCancelable(false) + .show() + } + + // common error message + private fun onError(errorMessage: String) { + Timber.e("## onError() : $errorMessage") + + // TODO + // Toast.makeText(this@AccountCreationCaptchaActivity, errorMessage, Toast.LENGTH_LONG).show() + + // on error case, close this activity + // runOnUiThread(Runnable { finish() }) + } + + @SuppressLint("NewApi") + override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { + super.onReceivedHttpError(view, request, errorResponse) + + if (request.url.toString().endsWith("favicon.ico")) { + // Ignore this error + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + onError(errorResponse.reasonPhrase) + } else { + onError(errorResponse.toString()) + } + } + + override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { + @Suppress("DEPRECATION") + super.onReceivedError(view, errorCode, description, failingUrl) + onError(description) + } + + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + if (url?.startsWith("js:") == true) { + var json = url.substring(3) + var javascriptResponse: JavascriptResponse? = null + + try { + // URL decode + json = URLDecoder.decode(json, "UTF-8") + javascriptResponse = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java).fromJson(json) + } catch (e: Exception) { + Timber.e(e, "## shouldOverrideUrlLoading(): failed") + } + + val response = javascriptResponse?.response + if (javascriptResponse?.action == "verifyCallback" && response != null) { + loginViewModel.handle(LoginAction2.CaptchaDone(response)) + } + } + return true + } + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignup) + } + + override fun updateWithState(state: LoginViewState2) { + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSigninPassword2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSigninPassword2.kt new file mode 100644 index 0000000000..a13905cc5f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSigninPassword2.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import com.airbnb.mvrx.Fail +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.showPassword +import im.vector.app.databinding.FragmentLoginSigninPassword2Binding +import im.vector.app.features.home.AvatarRenderer +import io.reactivex.rxkotlin.subscribeBy +import org.matrix.android.sdk.api.auth.login.LoginProfileInfo +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.isInvalidPassword +import javax.inject.Inject +import javax.net.ssl.HttpsURLConnection + +/** + * In this screen: + * - the user is asked for password to sign in to a homeserver. + * - He also can reset his password + */ +class LoginFragmentSigninPassword2 @Inject constructor( + private val avatarRenderer: AvatarRenderer +) : AbstractSSOLoginFragment2() { + + private var passwordShown = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSigninPassword2Binding { + return FragmentLoginSigninPassword2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupForgottenPasswordButton() + setupPasswordReveal() + setupAutoFill() + + views.passwordField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupForgottenPasswordButton() { + views.forgetPasswordButton.setOnClickListener { forgetPasswordClicked() } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) + } + } + + private fun submit() { + cleanupUi() + + val password = views.passwordField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.error_empty_field_your_password) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserPassword(password)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.passwordFieldTil.error = null + } + + private fun setupUi(state: LoginViewState2) { + // Name and avatar + views.loginWelcomeBack.text = getString( + R.string.login_welcome_back, + state.loginProfileInfo()?.displayName?.takeIf { it.isNotBlank() } ?: state.userIdentifier() + ) + + avatarRenderer.render( + profileInfo = state.loginProfileInfo() ?: LoginProfileInfo(state.userIdentifier(), null, null), + imageView = views.loginUserIcon + ) + + views.loginWelcomeBackWarning.isVisible = ((state.loginProfileInfo as? Fail) + ?.error as? Failure.ServerError) + ?.httpCode == HttpsURLConnection.HTTP_NOT_FOUND /* 404 */ + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.passwordField + .textChanges() + .map { it.isNotEmpty() } + .subscribeBy { + views.passwordFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + private fun forgetPasswordClicked() { + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OpenResetPasswordScreen)) + } + + private fun setupPasswordReveal() { + passwordShown = false + + views.passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordField.showPassword(passwordShown) + views.passwordReveal.render(passwordShown) + } + + override fun resetViewModel() { + // loginViewModel.handle(LoginAction2.ResetSignin) + } + + override fun onError(throwable: Throwable) { + if (throwable.isInvalidPassword() && spaceInPassword()) { + views.passwordFieldTil.error = getString(R.string.auth_invalid_login_param_space_in_password) + } else { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + + if (state.isLoading) { + // Ensure password is hidden + passwordShown = false + renderPasswordField() + } + } + + /** + * Detect if password ends or starts with spaces + */ + private fun spaceInPassword() = views.passwordField.text.toString().let { it.trim() != it } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSigninUsername2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSigninUsername2.kt new file mode 100644 index 0000000000..10fe0aae3a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSigninUsername2.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.autofill.HintConstants +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.databinding.FragmentLoginSigninUsername2Binding +import io.reactivex.rxkotlin.subscribeBy +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked for its matrix ID, and have the possibility to open the screen to select a server + */ +class LoginFragmentSigninUsername2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSigninUsername2Binding { + return FragmentLoginSigninUsername2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupAutoFill() + views.loginChooseAServer.setOnClickListener { + loginViewModel.handle(LoginAction2.ChooseAServerForSignin) + } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) + } + } + + private fun submit() { + cleanupUi() + + val login = views.loginField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString(R.string.error_empty_field_enter_user_name) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserName(login)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.loginField.textChanges() + .map { it.trim().isNotEmpty() } + .subscribeBy { + views.loginFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignin) + } + + override fun onError(throwable: Throwable) { + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_FORBIDDEN + && throwable.error.message.isEmpty()) { + // Login with email, but email unknown + views.loginFieldTil.error = getString(R.string.login_login_with_email_error) + } else { + views.loginFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSignupPassword2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSignupPassword2.kt new file mode 100644 index 0000000000..c67579e98d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSignupPassword2.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.showPassword +import im.vector.app.databinding.FragmentLoginSignupPassword2Binding +import io.reactivex.rxkotlin.subscribeBy +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked to choose a password to sign up to a homeserver. + */ +class LoginFragmentSignupPassword2 @Inject constructor() : AbstractLoginFragment2() { + + private var passwordShown = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSignupPassword2Binding { + return FragmentLoginSignupPassword2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupAutoFill() + setupPasswordReveal() + + views.passwordField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + } + + private fun submit() { + cleanupUi() + + val password = views.passwordField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.error_empty_field_choose_password) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserPassword(password)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.passwordFieldTil.error = null + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.passwordField.textChanges() + .subscribeBy { password -> + views.passwordFieldTil.error = null + views.loginSubmit.isEnabled = password.isNotEmpty() + } + .disposeOnDestroyView() + } + + private fun setupPasswordReveal() { + passwordShown = false + + views.passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordReveal.render(passwordShown) + views.passwordField.showPassword(passwordShown) + } + + override fun resetViewModel() { + // loginViewModel.handle(LoginAction2.ResetSignup) + } + + override fun onError(throwable: Throwable) { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + + override fun updateWithState(state: LoginViewState2) { + views.loginMatrixIdentifier.text = state.userIdentifier() + + if (state.isLoading) { + // Ensure password is hidden + passwordShown = false + renderPasswordField() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSignupUsername2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSignupUsername2.kt new file mode 100644 index 0000000000..ff6a218796 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentSignupUsername2.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginSignupUsername2Binding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SocialLoginButtonsView +import io.reactivex.rxkotlin.subscribeBy +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked for an identifier to sign up to a homeserver. + * - SSO option are displayed if available + */ +class LoginFragmentSignupUsername2 @Inject constructor() : AbstractSSOLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSignupUsername2Binding { + return FragmentLoginSignupUsername2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupAutoFill() + setupSocialLoginButtons() + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) + } + } + + private fun setupSocialLoginButtons() { + views.loginSocialLoginButtons.mode = SocialLoginButtonsView.Mode.MODE_SIGN_UP + } + + private fun submit() { + cleanupUi() + + val login = views.loginField.text.toString().trim() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString(R.string.error_empty_field_choose_user_name) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserName(login)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + } + + private fun setupUi(state: LoginViewState2) { + views.loginSubtitle.text = getString(R.string.login_signup_to, state.homeServerUrlFromUser.toReducedUrl()) + + if (state.loginMode is LoginMode.SsoAndPassword) { + views.loginSocialLoginContainer.isVisible = true + views.loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders?.sorted() + views.loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = id + ) + ?.let { openInCustomTab(it) } + } + } + } else { + views.loginSocialLoginContainer.isVisible = false + views.loginSocialLoginButtons.ssoIdentityProviders = null + } + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.loginField.textChanges() + .map { it.trim() } + .subscribeBy { text -> + val isNotEmpty = text.isNotEmpty() + views.loginFieldTil.error = null + views.loginSubmit.isEnabled = isNotEmpty + } + .disposeOnDestroyView() + } + + override fun resetViewModel() { + // loginViewModel.handle(LoginAction2.ResetSignup) + } + + override fun onError(throwable: Throwable) { + views.loginFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + + @SuppressLint("SetTextI18n") + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt new file mode 100644 index 0000000000..892699723e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.showPassword +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginSigninToAny2Binding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SocialLoginButtonsView +import io.reactivex.Observable +import io.reactivex.rxkotlin.subscribeBy +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import org.matrix.android.sdk.api.failure.isInvalidPassword +import javax.inject.Inject + +/** + * In this screen: + * User want to sign in and has selected a server to do so + * - the user is asked for login (or email) and password to sign in to a homeserver. + * - He also can reset his password + * - It also possible to use SSO if server support it in this screen + */ +class LoginFragmentToAny2 @Inject constructor() : AbstractSSOLoginFragment2() { + + private var passwordShown = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSigninToAny2Binding { + return FragmentLoginSigninToAny2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupForgottenPasswordButton() + setupPasswordReveal() + setupAutoFill() + setupSocialLoginButtons() + + views.passwordField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupForgottenPasswordButton() { + views.forgetPasswordButton.setOnClickListener { forgetPasswordClicked() } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) + } + } + + private fun setupSocialLoginButtons() { + views.loginSocialLoginButtons.mode = SocialLoginButtonsView.Mode.MODE_SIGN_IN + } + + private fun submit() { + cleanupUi() + + val login = views.loginField.text.toString() + val password = views.passwordField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString(R.string.error_empty_field_enter_user_name) + error++ + } + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.error_empty_field_your_password) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.LoginWith(login, password)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + views.passwordFieldTil.error = null + } + + private fun setupUi(state: LoginViewState2) { + views.loginTitle.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl()) + + if (state.loginMode is LoginMode.SsoAndPassword) { + views.loginSocialLoginContainer.isVisible = true + views.loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders?.sorted() + views.loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = id + ) + ?.let { openInCustomTab(it) } + } + } + } else { + views.loginSocialLoginContainer.isVisible = false + views.loginSocialLoginButtons.ssoIdentityProviders = null + } + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + Observable + .combineLatest( + views.loginField.textChanges().map { it.trim().isNotEmpty() }, + views.passwordField.textChanges().map { it.isNotEmpty() }, + { isLoginNotEmpty, isPasswordNotEmpty -> + isLoginNotEmpty && isPasswordNotEmpty + } + ) + .subscribeBy { + views.loginFieldTil.error = null + views.passwordFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + private fun forgetPasswordClicked() { + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OpenResetPasswordScreen)) + } + + private fun setupPasswordReveal() { + passwordShown = false + + views.passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordField.showPassword(passwordShown) + views.passwordReveal.render(passwordShown) + } + + override fun resetViewModel() { + // loginViewModel.handle(LoginAction2.ResetSignin) + } + + override fun onError(throwable: Throwable) { + // Show M_WEAK_PASSWORD error in the password field + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_WEAK_PASSWORD) { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } else { + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_FORBIDDEN + && throwable.error.message.isEmpty()) { + // Login with email, but email unknown + views.loginFieldTil.error = getString(R.string.login_login_with_email_error) + } else { + // Trick to display the error without text. + views.loginFieldTil.error = " " + if (throwable.isInvalidPassword() && spaceInPassword()) { + views.passwordFieldTil.error = getString(R.string.auth_invalid_login_param_space_in_password) + } else { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } + } + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + + if (state.isLoading) { + // Ensure password is hidden + passwordShown = false + renderPasswordField() + } + } + + /** + * Detect if password ends or starts with spaces + */ + private fun spaceInPassword() = views.passwordField.text.toString().let { it.trim() != it } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt new file mode 100644 index 0000000000..a87dd6ae40 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Build +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.isEmail +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginGenericTextInputForm2Binding +import im.vector.app.features.login.LoginGenericTextInputFormFragmentArgument +import im.vector.app.features.login.TextInputFormFragmentMode +import org.matrix.android.sdk.api.auth.registration.RegisterThreePid +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +/** + * In this screen, the user is asked for a text input + */ +class LoginGenericTextInputFormFragment2 @Inject constructor() : AbstractLoginFragment2() { + + private val params: LoginGenericTextInputFormFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginGenericTextInputForm2Binding { + return FragmentLoginGenericTextInputForm2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + setupUi() + setupSubmitButton() + setupTil() + setupAutoFill() + } + + private fun setupViews() { + views.loginGenericTextInputFormOtherButton.setOnClickListener { onOtherButtonClicked() } + views.loginGenericTextInputFormSubmit.setOnClickListener { submit() } + views.loginGenericTextInputFormLater.setOnClickListener { submit() } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginGenericTextInputFormTextInput.setAutofillHints( + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS + TextInputFormFragmentMode.SetMsisdn -> HintConstants.AUTOFILL_HINT_PHONE_NUMBER + TextInputFormFragmentMode.ConfirmMsisdn -> HintConstants.AUTOFILL_HINT_SMS_OTP + } + ) + } + } + + private fun setupTil() { + views.loginGenericTextInputFormTextInput.textChanges() + .subscribe { + views.loginGenericTextInputFormTil.error = null + } + .disposeOnDestroyView() + } + + private fun setupUi() { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + views.loginGenericTextInputFormTitle.text = getString(R.string.login_set_email_title_2) + views.loginGenericTextInputFormNotice.text = getString(R.string.login_set_email_notice_2) + // Text will be updated with the state + views.loginGenericTextInputFormMandatoryNotice.isVisible = params.mandatory + views.loginGenericTextInputFormNotice2.isVisible = false + views.loginGenericTextInputFormTil.hint = + getString(if (params.mandatory) R.string.login_set_email_mandatory_hint else R.string.login_set_email_optional_hint) + views.loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + views.loginGenericTextInputFormOtherButton.isVisible = false + views.loginGenericTextInputFormSubmit.text = getString(R.string.login_set_email_submit) + } + TextInputFormFragmentMode.SetMsisdn -> { + views.loginGenericTextInputFormTitle.text = getString(R.string.login_set_msisdn_title_2) + views.loginGenericTextInputFormNotice.text = getString(R.string.login_set_msisdn_notice_2) + // Text will be updated with the state + views.loginGenericTextInputFormMandatoryNotice.isVisible = params.mandatory + views.loginGenericTextInputFormNotice2.setTextOrHide(getString(R.string.login_set_msisdn_notice2)) + views.loginGenericTextInputFormTil.hint = + getString(if (params.mandatory) R.string.login_set_msisdn_mandatory_hint else R.string.login_set_msisdn_optional_hint) + views.loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_PHONE + views.loginGenericTextInputFormOtherButton.isVisible = false + views.loginGenericTextInputFormSubmit.text = getString(R.string.login_set_msisdn_submit) + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + views.loginGenericTextInputFormTitle.text = getString(R.string.login_msisdn_confirm_title) + views.loginGenericTextInputFormNotice.text = getString(R.string.login_msisdn_confirm_notice, params.extra) + views.loginGenericTextInputFormMandatoryNotice.isVisible = false + views.loginGenericTextInputFormNotice2.isVisible = false + views.loginGenericTextInputFormTil.hint = + getString(R.string.login_msisdn_confirm_hint) + views.loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_NUMBER + views.loginGenericTextInputFormOtherButton.isVisible = true + views.loginGenericTextInputFormOtherButton.text = getString(R.string.login_msisdn_confirm_send_again) + views.loginGenericTextInputFormSubmit.text = getString(R.string.login_msisdn_confirm_submit) + } + } + } + + private fun onOtherButtonClicked() { + when (params.mode) { + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginViewModel.handle(LoginAction2.SendAgainThreePid) + } + else -> { + // Should not happen, button is not displayed + } + } + } + + private fun submit() { + cleanupUi() + val text = views.loginGenericTextInputFormTextInput.text.toString() + + if (text.isEmpty()) { + // Perform dummy action + loginViewModel.handle(LoginAction2.RegisterDummy) + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + loginViewModel.handle(LoginAction2.AddThreePid(RegisterThreePid.Email(text))) + } + TextInputFormFragmentMode.SetMsisdn -> { + getCountryCodeOrShowError(text)?.let { countryCode -> + loginViewModel.handle(LoginAction2.AddThreePid(RegisterThreePid.Msisdn(text, countryCode))) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginViewModel.handle(LoginAction2.ValidateThreePid(text)) + } + } + } + } + + private fun cleanupUi() { + views.loginGenericTextInputFormSubmit.hideKeyboard() + views.loginGenericTextInputFormSubmit.error = null + } + + private fun getCountryCodeOrShowError(text: String): String? { + // We expect an international format for the moment (see https://github.com/vector-im/riotX-android/issues/693) + if (text.startsWith("+")) { + try { + val phoneNumber = PhoneNumberUtil.getInstance().parse(text, null) + return PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(phoneNumber.countryCode) + } catch (e: NumberParseException) { + views.loginGenericTextInputFormTil.error = getString(R.string.login_msisdn_error_other) + } + } else { + views.loginGenericTextInputFormTil.error = getString(R.string.login_msisdn_error_not_international) + } + + // Error + return null + } + + private fun setupSubmitButton() { + views.loginGenericTextInputFormSubmit.isEnabled = false + views.loginGenericTextInputFormTextInput.textChanges() + .subscribe { text -> + views.loginGenericTextInputFormSubmit.isEnabled = isInputValid(text) + text?.let { updateSubmitButtons(it) } + } + .disposeOnDestroyView() + } + + private fun updateSubmitButtons(text: CharSequence) { + if (params.mandatory) { + views.loginGenericTextInputFormSubmit.isVisible = true + views.loginGenericTextInputFormLater.isVisible = false + } else { + views.loginGenericTextInputFormSubmit.isVisible = text.isNotEmpty() + views.loginGenericTextInputFormLater.isVisible = text.isEmpty() + } + } + + private fun isInputValid(input: CharSequence): Boolean { + return if (input.isEmpty() && !params.mandatory) { + true + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> input.isEmail() + TextInputFormFragmentMode.SetMsisdn -> input.isNotBlank() + TextInputFormFragmentMode.ConfirmMsisdn -> input.isNotBlank() + } + } + } + + override fun onError(throwable: Throwable) { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + if (throwable.is401()) { + // This is normal use case, we go to the mail waiting screen + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnSendEmailSuccess(loginViewModel.currentThreePid ?: ""))) + } else { + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + TextInputFormFragmentMode.SetMsisdn -> { + if (throwable.is401()) { + // This is normal use case, we go to the enter code screen + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnSendMsisdnSuccess(loginViewModel.currentThreePid ?: ""))) + } else { + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + when { + throwable is Failure.SuccessError -> + // The entered code is not correct + views.loginGenericTextInputFormTil.error = getString(R.string.login_validation_code_is_not_correct) + throwable.is401() -> + // It can happen if user request again the 3pid + Unit + else -> + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignup) + } + + override fun updateWithState(state: LoginViewState2) { + views.loginGenericTextInputFormMandatoryNotice.text = when (params.mode) { + TextInputFormFragmentMode.SetEmail -> getString(R.string.login_set_email_mandatory_notice_2, state.homeServerUrlFromUser.toReducedUrl()) + TextInputFormFragmentMode.SetMsisdn -> getString(R.string.login_set_msisdn_mandatory_notice_2, state.homeServerUrlFromUser.toReducedUrl()) + TextInputFormFragmentMode.ConfirmMsisdn -> null + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt new file mode 100644 index 0000000000..c37c5ee44f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt @@ -0,0 +1,185 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.appcompat.app.AlertDialog +import androidx.autofill.HintConstants +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.isEmail +import im.vector.app.core.extensions.showPassword +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.core.utils.autoResetTextInputLayoutErrors +import im.vector.app.databinding.FragmentLoginResetPassword2Binding +import io.reactivex.Observable +import io.reactivex.rxkotlin.subscribeBy + +import javax.inject.Inject + +/** + * In this screen, the user is asked for email and new password to reset his password + */ +class LoginResetPasswordFragment2 @Inject constructor() : AbstractLoginFragment2() { + + private var passwordShown = false + + // Show warning only once + private var showWarning = true + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPassword2Binding { + return FragmentLoginResetPassword2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupPasswordReveal() + setupAutoFill() + + autoResetTextInputLayoutErrors(listOf(views.resetPasswordEmailTil, views.passwordFieldTil)) + + views.passwordField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.resetPasswordEmail.setAutofillHints(HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS) + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + } + + private fun setupUi(state: LoginViewState2) { + views.resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrlFromUser.toReducedUrl()) + } + + private fun setupSubmitButton() { + views.resetPasswordSubmit.setOnClickListener { submit() } + + Observable + .combineLatest( + views.resetPasswordEmail.textChanges().map { it.isEmail() }, + views.passwordField.textChanges().map { it.isNotEmpty() }, + { isEmail, isPasswordNotEmpty -> + isEmail && isPasswordNotEmpty + } + ) + .subscribeBy { + views.resetPasswordSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + private fun submit() { + cleanupUi() + + var error = 0 + + val email = views.resetPasswordEmail.text.toString() + val password = views.passwordField.text.toString() + + if (email.isEmpty()) { + views.resetPasswordEmailTil.error = getString(R.string.auth_reset_password_missing_email) + error++ + } + + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.login_please_choose_a_new_password) + error++ + } + + if (error > 0) { + return + } + + if (showWarning) { + // Display a warning as Riot-Web does first + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_reset_password_warning_title) + .setMessage(R.string.login_reset_password_warning_content) + .setPositiveButton(R.string.login_reset_password_warning_submit) { _, _ -> + showWarning = false + doSubmit() + } + .setNegativeButton(R.string.cancel, null) + .show() + } else { + doSubmit() + } + } + + private fun doSubmit() { + val email = views.resetPasswordEmail.text.toString() + val password = views.passwordField.text.toString() + + loginViewModel.handle(LoginAction2.ResetPassword(email, password)) + } + + private fun cleanupUi() { + views.resetPasswordSubmit.hideKeyboard() + views.resetPasswordEmailTil.error = null + views.passwordFieldTil.error = null + } + + private fun setupPasswordReveal() { + passwordShown = false + + views.passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordField.showPassword(passwordShown) + views.passwordReveal.render(passwordShown) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetResetPassword) + } + + override fun onError(throwable: Throwable) { + views.resetPasswordEmailTil.error = errorFormatter.toHumanReadable(throwable) + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + + if (state.isLoading) { + // Ensure new password is hidden + passwordShown = false + renderPasswordField() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt new file mode 100644 index 0000000000..65891e670e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import im.vector.app.R +import im.vector.app.databinding.FragmentLoginResetPasswordMailConfirmation2Binding +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +/** + * In this screen, the user is asked to check his email and to click on a button once it's done + */ +class LoginResetPasswordMailConfirmationFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPasswordMailConfirmation2Binding { + return FragmentLoginResetPasswordMailConfirmation2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.resetPasswordMailConfirmationSubmit.setOnClickListener { submit() } + } + + private fun setupUi(state: LoginViewState2) { + views.resetPasswordMailConfirmationNotice.text = getString(R.string.login_reset_password_mail_confirmation_notice, state.resetPasswordEmail) + } + + private fun submit() { + loginViewModel.handle(LoginAction2.ResetPasswordMailConfirmed) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetResetPassword) + } + + override fun onError(throwable: Throwable) { + // Link in email not yet clicked ? + val message = if (throwable.is401()) { + getString(R.string.auth_reset_password_error_unauthorized) + } else { + errorFormatter.toHumanReadable(throwable) + } + + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt new file mode 100644 index 0000000000..04a1453641 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.databinding.FragmentLoginResetPasswordSuccess2Binding + +import javax.inject.Inject + +/** + * In this screen, we confirm to the user that his password has been reset + */ +class LoginResetPasswordSuccessFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPasswordSuccess2Binding { + return FragmentLoginResetPasswordSuccess2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.resetPasswordSuccessSubmit.setOnClickListener { submit() } + } + + private fun submit() { + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnResetPasswordMailConfirmationSuccessDone)) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetResetPassword) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt new file mode 100644 index 0000000000..60e381b047 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.R +import im.vector.app.core.extensions.setTextWithColoredPart +import im.vector.app.core.utils.openUrlInChromeCustomTab +import im.vector.app.databinding.FragmentLoginServerSelection2Binding +import im.vector.app.features.login.EMS_LINK +import javax.inject.Inject + +/** + * In this screen, the user will choose between matrix.org, or other type of homeserver + */ +class LoginServerSelectionFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginServerSelection2Binding { + return FragmentLoginServerSelection2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initViews() + } + + private fun initViews() { + views.loginServerChoiceMatrixOrg.setOnClickListener { selectMatrixOrg() } + views.loginServerChoiceOther.setOnClickListener { selectOther() } + + views.loginServerChoiceEmsLearnMore.setTextWithColoredPart( + fullTextRes = R.string.login_server_modular_learn_more_about_ems, + coloredTextRes = R.string.login_server_modular_learn_more, + underline = true + ) + views.loginServerChoiceEmsLearnMore.setOnClickListener { + openUrlInChromeCustomTab(requireActivity(), null, EMS_LINK) + } + } + + private fun updateUi(state: LoginViewState2) { + when (state.signMode) { + SignMode2.Unknown -> Unit + SignMode2.SignUp -> { + views.loginServerTitle.setText(R.string.login_please_choose_a_server) + } + SignMode2.SignIn -> { + views.loginServerTitle.setText(R.string.login_please_select_your_server) + } + } + } + + private fun selectMatrixOrg() { + views.loginServerChoiceMatrixOrg.isChecked = true + loginViewModel.handle(LoginAction2.ChooseDefaultHomeServer) + } + + private fun selectOther() { + views.loginServerChoiceOther.isChecked = true + loginViewModel.handle(LoginAction2.EnterServerUrl) + } + + override fun onResume() { + super.onResume() + views.loginServerChoiceMatrixOrg.isChecked = false + views.loginServerChoiceOther.isChecked = false + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetHomeServerUrl) + } + + override fun updateWithState(state: LoginViewState2) { + updateUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt new file mode 100644 index 0000000000..74bc017128 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import androidx.core.view.isInvisible +import com.google.android.material.textfield.TextInputLayout +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.BuildConfig +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.utils.ensureProtocol +import im.vector.app.databinding.FragmentLoginServerUrlForm2Binding +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import java.net.UnknownHostException +import javax.inject.Inject +import javax.net.ssl.HttpsURLConnection + +/** + * In this screen, the user is prompted to enter a homeserver url + */ +class LoginServerUrlFormFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginServerUrlForm2Binding { + return FragmentLoginServerUrlForm2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + setupHomeServerField() + } + + private fun setupViews() { + views.loginServerUrlFormClearHistory.setOnClickListener { clearHistory() } + views.loginServerUrlFormSubmit.setOnClickListener { submit() } + } + + private fun setupHomeServerField() { + views.loginServerUrlFormHomeServerUrl.textChanges() + .subscribe { + views.loginServerUrlFormHomeServerUrlTil.error = null + views.loginServerUrlFormSubmit.isEnabled = it.isNotBlank() + } + .disposeOnDestroyView() + + views.loginServerUrlFormHomeServerUrl.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + views.loginServerUrlFormHomeServerUrl.dismissDropDown() + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupUi(state: LoginViewState2) { + val completions = state.knownCustomHomeServersUrls + if (BuildConfig.DEBUG) listOf("http://10.0.2.2:8080") else emptyList() + views.loginServerUrlFormHomeServerUrl.setAdapter(ArrayAdapter( + requireContext(), + R.layout.item_completion_homeserver, + completions + )) + views.loginServerUrlFormHomeServerUrlTil.endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU + .takeIf { completions.isNotEmpty() } + ?: TextInputLayout.END_ICON_NONE + + views.loginServerUrlFormClearHistory.isInvisible = state.knownCustomHomeServersUrls.isEmpty() + } + + private fun clearHistory() { + loginViewModel.handle(LoginAction2.ClearHomeServerHistory) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetHomeServerUrl) + } + + @SuppressLint("SetTextI18n") + private fun submit() { + cleanupUi() + + // Static check of homeserver url, empty, malformed, etc. + val serverUrl = views.loginServerUrlFormHomeServerUrl.text.toString().trim().ensureProtocol() + + when { + serverUrl.isBlank() -> { + views.loginServerUrlFormHomeServerUrlTil.error = getString(R.string.login_error_invalid_home_server) + } + else -> { + views.loginServerUrlFormHomeServerUrl.setText(serverUrl, false /* to avoid completion dialog flicker*/) + loginViewModel.handle(LoginAction2.UpdateHomeServer(serverUrl)) + } + } + } + + private fun cleanupUi() { + views.loginServerUrlFormSubmit.hideKeyboard() + views.loginServerUrlFormHomeServerUrlTil.error = null + } + + override fun onError(throwable: Throwable) { + views.loginServerUrlFormHomeServerUrlTil.error = if (throwable is Failure.NetworkConnection + && throwable.ioException is UnknownHostException) { + // Invalid homeserver? + getString(R.string.login_error_homeserver_not_found) + } else { + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_FORBIDDEN + && throwable.httpCode == HttpsURLConnection.HTTP_FORBIDDEN /* 403 */) { + getString(R.string.login_registration_disabled) + } else { + errorFormatter.toHumanReadable(throwable) + } + } + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginSplashSignUpSignInSelectionFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginSplashSignUpSignInSelectionFragment2.kt new file mode 100644 index 0000000000..6cfebf776a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginSplashSignUpSignInSelectionFragment2.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import im.vector.app.BuildConfig +import im.vector.app.databinding.FragmentLoginSplash2Binding +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +/** + * In this screen, the user is asked to sign up or to sign in to the homeserver + * This is the new splash screen + */ +class LoginSplashSignUpSignInSelectionFragment2 @Inject constructor( + private val vectorPreferences: VectorPreferences +) : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSplash2Binding { + return FragmentLoginSplash2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + } + + private fun setupViews() { + views.loginSignupSigninSignUp.setOnClickListener { signUp() } + views.loginSignupSigninSignIn.setOnClickListener { signIn() } + + if (BuildConfig.DEBUG || vectorPreferences.developerMode()) { + views.loginSplashVersion.isVisible = true + @SuppressLint("SetTextI18n") + views.loginSplashVersion.text = "Version : ${BuildConfig.VERSION_NAME}\n" + + "Branch: ${BuildConfig.GIT_BRANCH_NAME}\n" + + "Build: ${BuildConfig.BUILD_NUMBER}" + } + } + + private fun signUp() { + loginViewModel.handle(LoginAction2.UpdateSignMode(SignMode2.SignUp)) + } + + private fun signIn() { + loginViewModel.handle(LoginAction2.UpdateSignMode(SignMode2.SignIn)) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignMode) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt new file mode 100644 index 0000000000..edfa02f523 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginSsoOnly2Binding +import javax.inject.Inject + +/** + * In this screen, the user is asked to sign up or to sign in to the homeserver + */ +class LoginSsoOnlyFragment2 @Inject constructor() : AbstractSSOLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSsoOnly2Binding { + return FragmentLoginSsoOnly2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + } + + private fun setupViews() { + views.loginSignupSigninSubmit.setOnClickListener { submit() } + } + + private fun setupUi(state: LoginViewState2) { + views.loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl()) + } + + private fun submit() = withState(loginViewModel) { state -> + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null + ) + ?.let { openInCustomTab(it) } + } + + override fun resetViewModel() { + // No op + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt new file mode 100644 index 0000000000..54cf507a7e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package im.vector.app.features.login2 + +import im.vector.app.core.platform.VectorViewEvents +import org.matrix.android.sdk.api.auth.registration.FlowResult + +/** + * Transient events for Login + */ +sealed class LoginViewEvents2 : VectorViewEvents { + data class Failure(val throwable: Throwable) : LoginViewEvents2() + + data class RegistrationFlowResult(val flowResult: FlowResult, val isRegistrationStarted: Boolean) : LoginViewEvents2() + object OutdatedHomeserver : LoginViewEvents2() + + // Navigation event + object OpenSigninPasswordScreen : LoginViewEvents2() + object OpenSignupPasswordScreen : LoginViewEvents2() + + object OpenSignInEnterIdentifierScreen : LoginViewEvents2() + + object OpenSignUpChooseUsernameScreen : LoginViewEvents2() + object OpenSignInWithAnythingScreen : LoginViewEvents2() + + object OpenSsoOnlyScreen : LoginViewEvents2() + + object OpenServerSelection : LoginViewEvents2() + object OpenHomeServerUrlFormScreen : LoginViewEvents2() + + object OpenResetPasswordScreen : LoginViewEvents2() + object OnResetPasswordSendThreePidDone : LoginViewEvents2() + object OnResetPasswordMailConfirmationSuccess : LoginViewEvents2() + object OnResetPasswordMailConfirmationSuccessDone : LoginViewEvents2() + + object CancelRegistration: LoginViewEvents2() + + data class OnLoginModeNotSupported(val supportedTypes: List) : LoginViewEvents2() + + data class OnSendEmailSuccess(val email: String) : LoginViewEvents2() + data class OnSendMsisdnSuccess(val msisdn: String) : LoginViewEvents2() + + data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : LoginViewEvents2() + + data class OnSessionCreated(val newAccount: Boolean): LoginViewEvents2() + + object Finish : LoginViewEvents2() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt new file mode 100644 index 0000000000..2e457551dc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt @@ -0,0 +1,836 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.content.Context +import android.net.Uri +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.R +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.extensions.configureAndStart +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.tryAsync +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.utils.ensureTrailingSlash +import im.vector.app.features.login.HomeServerConnectionConfigFactory +import im.vector.app.features.login.LoginConfig +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.ReAuthHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.auth.data.LoginFlowTypes +import org.matrix.android.sdk.api.auth.login.LoginWizard +import org.matrix.android.sdk.api.auth.registration.FlowResult +import org.matrix.android.sdk.api.auth.registration.RegistrationAvailability +import org.matrix.android.sdk.api.auth.registration.RegistrationResult +import org.matrix.android.sdk.api.auth.registration.RegistrationWizard +import org.matrix.android.sdk.api.auth.registration.Stage +import org.matrix.android.sdk.api.auth.wellknown.WellknownResult +import org.matrix.android.sdk.api.session.Session +import timber.log.Timber +import java.util.concurrent.CancellationException + +/** + * + */ +class LoginViewModel2 @AssistedInject constructor( + @Assisted initialState: LoginViewState2, + private val applicationContext: Context, + private val authenticationService: AuthenticationService, + private val activeSessionHolder: ActiveSessionHolder, + private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory, + private val reAuthHelper: ReAuthHelper, + private val stringProvider: StringProvider, + private val homeServerHistoryService: HomeServerHistoryService +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: LoginViewState2): LoginViewModel2 + } + + init { + getKnownCustomHomeServersUrls() + } + + private fun getKnownCustomHomeServersUrls() { + setState { + copy(knownCustomHomeServersUrls = homeServerHistoryService.getKnownServersUrls()) + } + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: LoginViewState2): LoginViewModel2? { + return when (val activity: FragmentActivity = (viewModelContext as ActivityViewModelContext).activity()) { + is LoginActivity2 -> activity.loginViewModelFactory.create(state) + // TODO is SoftLogoutActivity -> activity.loginViewModelFactory.create(state) + else -> error("Invalid Activity") + } + } + } + + // Store the last action, to redo it after user has trusted the untrusted certificate + private var lastAction: LoginAction2? = null + private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null + + private val matrixOrgUrl = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash() + + val currentThreePid: String? + get() = registrationWizard?.currentThreePid + + // True when login and password has been sent with success to the homeserver + val isRegistrationStarted: Boolean + get() = authenticationService.isRegistrationStarted + + private val registrationWizard: RegistrationWizard? + get() = authenticationService.getRegistrationWizard() + + private val loginWizard: LoginWizard? + get() = authenticationService.getLoginWizard() + + private var loginConfig: LoginConfig? = null + + private var currentJob: Job? = null + set(value) { + // Cancel any previous Job + field?.cancel() + field = value + } + + override fun handle(action: LoginAction2) { + when (action) { + is LoginAction2.EnterServerUrl -> handleEnterServerUrl() + is LoginAction2.ChooseAServerForSignin -> handleChooseAServerForSignin() + is LoginAction2.UpdateSignMode -> handleUpdateSignMode(action) + is LoginAction2.InitWith -> handleInitWith(action) + is LoginAction2.ChooseDefaultHomeServer -> handle(LoginAction2.UpdateHomeServer(matrixOrgUrl)) + is LoginAction2.UpdateHomeServer -> handleUpdateHomeserver(action).also { lastAction = action } + is LoginAction2.SetUserName -> handleSetUserName(action).also { lastAction = action } + is LoginAction2.SetUserPassword -> handleSetUserPassword(action).also { lastAction = action } + is LoginAction2.LoginWith -> handleLoginWith(action).also { lastAction = action } + is LoginAction2.LoginWithToken -> handleLoginWithToken(action) + is LoginAction2.WebLoginSuccess -> handleWebLoginSuccess(action) + is LoginAction2.ResetPassword -> handleResetPassword(action) + is LoginAction2.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() + is LoginAction2.RegisterAction -> handleRegisterAction(action) + is LoginAction2.ResetAction -> handleResetAction(action) + is LoginAction2.SetupSsoForSessionRecovery -> handleSetupSsoForSessionRecovery(action) + is LoginAction2.UserAcceptCertificate -> handleUserAcceptCertificate(action) + LoginAction2.ClearHomeServerHistory -> handleClearHomeServerHistory() + is LoginAction2.PostViewEvent -> _viewEvents.post(action.viewEvent) + is LoginAction2.Finish -> handleFinish() + }.exhaustive + } + + private fun handleFinish() { + // Just post a view Event + _viewEvents.post(LoginViewEvents2.Finish) + } + + private fun handleChooseAServerForSignin() { + // Just post a view Event + _viewEvents.post(LoginViewEvents2.OpenServerSelection) + } + + private fun handleUserAcceptCertificate(action: LoginAction2.UserAcceptCertificate) { + // It happens when we get the login flow, or during direct authentication. + // So alter the homeserver config and retrieve again the login flow + when (val finalLastAction = lastAction) { + is LoginAction2.UpdateHomeServer -> { + currentHomeServerConnectionConfig + ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } + ?.let { getLoginFlow(it) } + } + is LoginAction2.SetUserName -> + handleSetUserNameForSignIn( + finalLastAction, + HomeServerConnectionConfig.Builder() + // Will be replaced by the task + .withHomeServerUri("https://dummy.org") + .withAllowedFingerPrints(listOf(action.fingerprint)) + .build() + ) + is LoginAction2.SetUserPassword -> + handleSetUserPassword(finalLastAction) + is LoginAction2.LoginWith -> + handleLoginWith(finalLastAction) + } + } + + private fun rememberHomeServer(homeServerUrl: String) { + homeServerHistoryService.addHomeServerToHistory(homeServerUrl) + getKnownCustomHomeServersUrls() + } + + private fun handleClearHomeServerHistory() { + homeServerHistoryService.clearHistory() + getKnownCustomHomeServersUrls() + } + + private fun handleLoginWithToken(action: LoginAction2.LoginWithToken) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + } else { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + try { + safeLoginWizard.loginWithToken(action.loginToken) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + ?.let { onSessionCreated(it) } + + setState { copy(isLoading = false) } + } + } + } + + private fun handleSetupSsoForSessionRecovery(action: LoginAction2.SetupSsoForSessionRecovery) { + setState { + copy( + signMode = SignMode2.SignIn, + loginMode = LoginMode.Sso(action.ssoIdentityProviders), + homeServerUrlFromUser = action.homeServerUrl, + homeServerUrl = action.homeServerUrl, + deviceId = action.deviceId + ) + } + } + + private fun handleRegisterAction(action: LoginAction2.RegisterAction) { + when (action) { + is LoginAction2.CaptchaDone -> handleCaptchaDone(action) + is LoginAction2.AcceptTerms -> handleAcceptTerms() + is LoginAction2.RegisterDummy -> handleRegisterDummy() + is LoginAction2.AddThreePid -> handleAddThreePid(action) + is LoginAction2.SendAgainThreePid -> handleSendAgainThreePid() + is LoginAction2.ValidateThreePid -> handleValidateThreePid(action) + is LoginAction2.CheckIfEmailHasBeenValidated -> handleCheckIfEmailHasBeenValidated(action) + is LoginAction2.StopEmailValidationCheck -> handleStopEmailValidationCheck() + } + } + + private fun handleCheckIfEmailHasBeenValidated(action: LoginAction2.CheckIfEmailHasBeenValidated) { + // We do not want the common progress bar to be displayed, so we do not change asyncRegistration value in the state + currentJob = executeRegistrationStep(withLoading = false) { + it.checkIfEmailHasBeenValidated(action.delayMillis) + } + } + + private fun handleStopEmailValidationCheck() { + currentJob = null + } + + private fun handleValidateThreePid(action: LoginAction2.ValidateThreePid) { + currentJob = executeRegistrationStep { + it.handleValidateThreePid(action.code) + } + } + + private fun executeRegistrationStep(withLoading: Boolean = true, + block: suspend (RegistrationWizard) -> RegistrationResult): Job { + if (withLoading) { + setState { copy(isLoading = true) } + } + return viewModelScope.launch { + try { + registrationWizard?.let { block(it) } + } catch (failure: Throwable) { + if (failure !is CancellationException) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + } + null + } + ?.let { data -> + when (data) { + is RegistrationResult.Success -> onSessionCreated(data.session) + is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult) + } + } + + setState { copy(isLoading = false) } + } + } + + private fun handleAddThreePid(action: LoginAction2.AddThreePid) { + setState { copy(isLoading = true) } + currentJob = viewModelScope.launch { + try { + registrationWizard?.addThreePid(action.threePid) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + } + setState { copy(isLoading = false) } + } + } + + private fun handleSendAgainThreePid() { + setState { copy(isLoading = true) } + currentJob = viewModelScope.launch { + try { + registrationWizard?.sendAgainThreePid() + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + } + setState { copy(isLoading = false) } + } + } + + private fun handleAcceptTerms() { + currentJob = executeRegistrationStep { + it.acceptTerms() + } + } + + private fun handleRegisterDummy() { + currentJob = executeRegistrationStep { + it.dummy() + } + } + + /** + * Check that the user name is available + */ + private fun handleSetUserNameForSignUp(action: LoginAction2.SetUserName) { + setState { copy(isLoading = true) } + + val safeRegistrationWizard = registrationWizard ?: error("Invalid") + + viewModelScope.launch { + val available = safeRegistrationWizard.registrationAvailable(action.username) + + val event = when (available) { + RegistrationAvailability.Available -> { + // Ask for a password + LoginViewEvents2.OpenSignupPasswordScreen + } + is RegistrationAvailability.NotAvailable -> { + LoginViewEvents2.Failure(available.failure) + } + } + _viewEvents.post(event) + setState { copy(isLoading = false) } + } + } + + private fun handleCaptchaDone(action: LoginAction2.CaptchaDone) { + currentJob = executeRegistrationStep { + it.performReCaptcha(action.captchaResponse) + } + } + + // TODO Update this + private fun handleResetAction(action: LoginAction2.ResetAction) { + // Cancel any request + currentJob = null + + when (action) { + LoginAction2.ResetHomeServerUrl -> { + viewModelScope.launch { + authenticationService.reset() + setState { + copy( + homeServerUrlFromUser = null, + homeServerUrl = null, + loginMode = LoginMode.Unknown + ) + } + } + } + LoginAction2.ResetSignMode -> { + setState { + copy( + signMode = SignMode2.Unknown, + loginMode = LoginMode.Unknown + ) + } + } + LoginAction2.ResetSignin -> { + viewModelScope.launch { + authenticationService.cancelPendingLoginOrRegistration() + setState { + copy(isLoading = false) + } + } + _viewEvents.post(LoginViewEvents2.CancelRegistration) + } + LoginAction2.ResetSignup -> { + viewModelScope.launch { + authenticationService.cancelPendingLoginOrRegistration() + setState { + // Always create a new state, to ensure the state is correctly reset + LoginViewState2( + knownCustomHomeServersUrls = knownCustomHomeServersUrls + ) + } + } + _viewEvents.post(LoginViewEvents2.CancelRegistration) + } + LoginAction2.ResetResetPassword -> { + setState { + copy( + resetPasswordEmail = null + ) + } + } + } + } + + private fun handleUpdateSignMode(action: LoginAction2.UpdateSignMode) { + setState { + copy( + signMode = action.signMode + ) + } + + when (action.signMode) { + SignMode2.SignUp -> _viewEvents.post(LoginViewEvents2.OpenServerSelection) + SignMode2.SignIn -> _viewEvents.post(LoginViewEvents2.OpenSignInEnterIdentifierScreen) + SignMode2.Unknown -> Unit + } + } + + private fun handleEnterServerUrl() { + _viewEvents.post(LoginViewEvents2.OpenHomeServerUrlFormScreen) + } + + private fun handleInitWith(action: LoginAction2.InitWith) { + loginConfig = action.loginConfig + + // If there is a pending email validation continue on this step + try { + if (registrationWizard?.isRegistrationStarted == true) { + currentThreePid?.let { + handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnSendEmailSuccess(it))) + } + } + } catch (e: Throwable) { + // NOOP. API is designed to use wizards in a login/registration flow, + // but we need to check the state anyway. + } + } + + private fun handleResetPassword(action: LoginAction2.ResetPassword) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + } else { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + try { + safeLoginWizard.resetPassword(action.email, action.newPassword) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + return@launch + } + + setState { + copy( + isLoading = false, + resetPasswordEmail = action.email + ) + } + + _viewEvents.post(LoginViewEvents2.OnResetPasswordSendThreePidDone) + } + } + } + + private fun handleResetPasswordMailConfirmed() { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + } else { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + try { + safeLoginWizard.resetPasswordMailConfirmed() + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + return@launch + } + setState { + copy( + isLoading = false, + resetPasswordEmail = null + ) + } + + _viewEvents.post(LoginViewEvents2.OnResetPasswordMailConfirmationSuccess) + } + } + } + + private fun handleSetUserName(action: LoginAction2.SetUserName) = withState { state -> + setState { + copy( + userName = action.username + ) + } + + when (state.signMode) { + SignMode2.Unknown -> error("Developer error, invalid sign mode") + SignMode2.SignIn -> handleSetUserNameForSignIn(action, null) + SignMode2.SignUp -> handleSetUserNameForSignUp(action) + }.exhaustive + } + + private fun handleSetUserPassword(action: LoginAction2.SetUserPassword) = withState { state -> + when (state.signMode) { + SignMode2.Unknown -> error("Developer error, invalid sign mode") + SignMode2.SignIn -> handleSignInWithPassword(action) + SignMode2.SignUp -> handleRegisterWithPassword(action) + }.exhaustive + } + + private fun handleRegisterWithPassword(action: LoginAction2.SetUserPassword) = withState { state -> + val username = state.userName ?: error("Developer error, username not set") + + reAuthHelper.data = action.password + currentJob = executeRegistrationStep { + it.createAccount( + userName = username, + password = action.password, + initialDeviceDisplayName = stringProvider.getString(R.string.login_default_session_public_name) + ) + } + } + + private fun handleSignInWithPassword(action: LoginAction2.SetUserPassword) = withState { state -> + val username = state.userName ?: error("Developer error, username not set") + setState { copy(isLoading = true) } + loginWith(username, action.password) + } + + private fun handleLoginWith(action: LoginAction2.LoginWith) { + setState { copy(isLoading = true) } + loginWith(action.login, action.password) + } + + private fun loginWith(login: String, password: String) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + setState { copy(isLoading = false) } + } else { + currentJob = viewModelScope.launch { + try { + safeLoginWizard.login( + login = login, + password = password, + deviceName = stringProvider.getString(R.string.login_default_session_public_name) + ) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + ?.let { + reAuthHelper.data = password + onSessionCreated(it) + } + setState { copy(isLoading = false) } + } + } + } + + /** + * Perform wellknown request + */ + private fun handleSetUserNameForSignIn(action: LoginAction2.SetUserName, homeServerConnectionConfig: HomeServerConnectionConfig?) { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + val data = try { + authenticationService.getWellKnownData(action.username, homeServerConnectionConfig) + } catch (failure: Throwable) { + onDirectLoginError(failure) + return@launch + } + when (data) { + is WellknownResult.Prompt -> + onWellknownSuccess(action, data, homeServerConnectionConfig) + is WellknownResult.FailPrompt -> + // Relax on IS discovery if home server is valid + if (data.homeServerUrl != null && data.wellKnown != null) { + onWellknownSuccess(action, WellknownResult.Prompt(data.homeServerUrl!!, null, data.wellKnown!!), homeServerConnectionConfig) + } else { + onWellKnownError() + } + is WellknownResult.InvalidMatrixId -> { + setState { copy(isLoading = false) } + _viewEvents.post(LoginViewEvents2.Failure(Exception(stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id)))) + } + else -> { + onWellKnownError() + } + }.exhaustive + } + } + + private fun onWellKnownError() { + _viewEvents.post(LoginViewEvents2.Failure(Exception(stringProvider.getString(R.string.autodiscover_well_known_error)))) + setState { copy(isLoading = false) } + } + + private suspend fun onWellknownSuccess(action: LoginAction2.SetUserName, + wellKnownPrompt: WellknownResult.Prompt, + homeServerConnectionConfig: HomeServerConnectionConfig?) { + val alteredHomeServerConnectionConfig = homeServerConnectionConfig + ?.copy( + homeServerUri = Uri.parse(wellKnownPrompt.homeServerUrl), + identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) } + ) + ?: HomeServerConnectionConfig( + homeServerUri = Uri.parse(wellKnownPrompt.homeServerUrl), + identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) } + ) + + // Ensure login flow is retrieved, and this is not a SSO only server + val data = try { + authenticationService.getLoginFlow(alteredHomeServerConnectionConfig) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } ?: return + + val loginMode = when { + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) + && data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password + else -> LoginMode.Unsupported + } + + val viewEvent = when (loginMode) { + LoginMode.Password, + is LoginMode.SsoAndPassword -> { + retrieveProfileInfo(action.username) + // We can navigate to the password screen + LoginViewEvents2.OpenSigninPasswordScreen + } + is LoginMode.Sso -> { + LoginViewEvents2.OpenSsoOnlyScreen + } + LoginMode.Unsupported -> LoginViewEvents2.OnLoginModeNotSupported(data.supportedLoginTypes.toList()) + LoginMode.Unknown -> null + } + viewEvent?.let { _viewEvents.post(it) } + + val urlFromUser = action.username.substringAfter(":") + setState { + copy( + isLoading = false, + homeServerUrlFromUser = urlFromUser, + homeServerUrl = data.homeServerUrl, + loginMode = loginMode + ) + } + + if ((loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) + || data.isOutdatedHomeserver) { + // Notify the UI + _viewEvents.post(LoginViewEvents2.OutdatedHomeserver) + } + } + + private suspend fun retrieveProfileInfo(username: String) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard != null) { + setState { copy(loginProfileInfo = Loading()) } + val result = tryAsync { + safeLoginWizard.getProfileInfo(username) + } + setState { copy(loginProfileInfo = result) } + } + } + + private fun onDirectLoginError(failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + } + + private fun onFlowResponse(flowResult: FlowResult) { + // If dummy stage is mandatory, and password is already sent, do the dummy stage now + if (isRegistrationStarted + && flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) { + handleRegisterDummy() + } else { + // Notify the user + _viewEvents.post(LoginViewEvents2.RegistrationFlowResult(flowResult, isRegistrationStarted)) + } + } + + private suspend fun onSessionCreated(session: Session) { + activeSessionHolder.setActiveSession(session) + + authenticationService.reset() + session.configureAndStart(applicationContext) + withState { state -> + _viewEvents.post(LoginViewEvents2.OnSessionCreated(state.signMode == SignMode2.SignUp)) + } + } + + private fun handleWebLoginSuccess(action: LoginAction2.WebLoginSuccess) = withState { state -> + val homeServerConnectionConfigFinal = homeServerConnectionConfigFactory.create(state.homeServerUrl) + + if (homeServerConnectionConfigFinal == null) { + // Should not happen + Timber.w("homeServerConnectionConfig is null") + } else { + currentJob = viewModelScope.launch { + try { + authenticationService.createSessionFromSso(homeServerConnectionConfigFinal, action.credentials) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + ?.let { onSessionCreated(it) } + } + } + } + + private fun handleUpdateHomeserver(action: LoginAction2.UpdateHomeServer) { + val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) + if (homeServerConnectionConfig == null) { + // This is invalid + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) + } else { + getLoginFlow(homeServerConnectionConfig) + } + } + + private fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig) = withState { state -> + currentHomeServerConnectionConfig = homeServerConnectionConfig + + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + authenticationService.cancelPendingLoginOrRegistration() + + val data = try { + authenticationService.getLoginFlow(homeServerConnectionConfig) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + null + } ?: return@launch + + // Valid Homeserver, add it to the history. + // Note: we add what the user has input, data.homeServerUrl can be different + rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString()) + + val loginMode = when { + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) + && data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password + else -> LoginMode.Unsupported + } + + val viewEvent = when (loginMode) { + LoginMode.Password, + is LoginMode.SsoAndPassword -> { + when (state.signMode) { + SignMode2.Unknown -> null + SignMode2.SignUp -> { + // Check that registration is possible on this server + try { + registrationWizard?.getRegistrationFlow() + + /* + // Simulate registration disabled + throw Failure.ServerError( + error = MatrixError( + code = MatrixError.M_FORBIDDEN, + message = "Registration is disabled" + ), + httpCode = 403 + ) + */ + + LoginViewEvents2.OpenSignUpChooseUsernameScreen + } catch (throwable: Throwable) { + // Registration disabled? + LoginViewEvents2.Failure(throwable) + } + } + SignMode2.SignIn -> LoginViewEvents2.OpenSignInWithAnythingScreen + } + } + is LoginMode.Sso -> { + LoginViewEvents2.OpenSsoOnlyScreen + } + LoginMode.Unsupported -> LoginViewEvents2.OnLoginModeNotSupported(data.supportedLoginTypes.toList()) + LoginMode.Unknown -> null + } + viewEvent?.let { _viewEvents.post(it) } + + if ((loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) + || data.isOutdatedHomeserver) { + // Notify the UI + _viewEvents.post(LoginViewEvents2.OutdatedHomeserver) + } + + setState { + copy( + isLoading = false, + homeServerUrlFromUser = homeServerConnectionConfig.homeServerUri.toString(), + homeServerUrl = data.homeServerUrl, + loginMode = loginMode + ) + } + } + } + + fun getInitialHomeServerUrl(): String? { + return loginConfig?.homeServerUrl + } + + fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?): String? { + return authenticationService.getSsoUrl(redirectUrl, deviceId, providerId) + } + + fun getFallbackUrl(forSignIn: Boolean, deviceId: String?): String? { + return authenticationService.getFallbackUrl(forSignIn, deviceId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt new file mode 100644 index 0000000000..d629c6dfe7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.PersistState +import com.airbnb.mvrx.Uninitialized +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.features.login.LoginMode +import org.matrix.android.sdk.api.MatrixPatterns +import org.matrix.android.sdk.api.auth.login.LoginProfileInfo + +data class LoginViewState2( + val isLoading: Boolean = false, + + // User choices + @PersistState + val signMode: SignMode2 = SignMode2.Unknown, + @PersistState + val userName: String? = null, + @PersistState + val resetPasswordEmail: String? = null, + @PersistState + val homeServerUrlFromUser: String? = null, + + // Can be modified after a Wellknown request + @PersistState + val homeServerUrl: String? = null, + + // For SSO session recovery + @PersistState + val deviceId: String? = null, + + // Network result + val loginProfileInfo: Async = Uninitialized, + + // Network result + @PersistState + val loginMode: LoginMode = LoginMode.Unknown, + + // From database + val knownCustomHomeServersUrls: List = emptyList() +) : MvRxState { + + // Pending user identifier + fun userIdentifier(): String { + return if (userName != null && MatrixPatterns.isUserId(userName)) { + userName + } else { + "@$userName:${homeServerUrlFromUser.toReducedUrl()}" + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt new file mode 100644 index 0000000000..0cac52b306 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.args +import im.vector.app.R +import im.vector.app.databinding.FragmentLoginWaitForEmail2Binding +import im.vector.app.features.login.LoginWaitForEmailFragmentArgument +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +/** + * In this screen, the user is asked to check his emails + */ +class LoginWaitForEmailFragment2 @Inject constructor() : AbstractLoginFragment2() { + + private val params: LoginWaitForEmailFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWaitForEmail2Binding { + return FragmentLoginWaitForEmail2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUi() + } + + override fun onResume() { + super.onResume() + + loginViewModel.handle(LoginAction2.CheckIfEmailHasBeenValidated(0)) + } + + override fun onPause() { + super.onPause() + + loginViewModel.handle(LoginAction2.StopEmailValidationCheck) + } + + private fun setupUi() { + views.loginWaitForEmailNotice.text = getString(R.string.login_wait_for_email_notice_2, params.email) + } + + override fun onError(throwable: Throwable) { + if (throwable.is401()) { + // Try again, with a delay + loginViewModel.handle(LoginAction2.CheckIfEmailHasBeenValidated(10_000)) + } else { + super.onError(throwable) + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignup) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt new file mode 100644 index 0000000000..fb9a498b97 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") + +package im.vector.app.features.login2 + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AlertDialog +import com.airbnb.mvrx.activityViewModel +import im.vector.app.R +import im.vector.app.core.utils.AssetReader +import im.vector.app.databinding.FragmentLoginWebBinding +import im.vector.app.features.login.JavascriptResponse +import im.vector.app.features.signout.soft.SoftLogoutAction +import im.vector.app.features.signout.soft.SoftLogoutViewModel + +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.internal.di.MoshiProvider +import timber.log.Timber +import java.net.URLDecoder +import javax.inject.Inject + +/** + * This screen is displayed when the application does not support login flow or registration flow + * of the homeserver, as a fallback to login or to create an account + */ +class LoginWebFragment2 @Inject constructor( + private val assetReader: AssetReader +) : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWebBinding { + return FragmentLoginWebBinding.inflate(inflater, container, false) + } + + private var isWebViewLoaded = false + private var isForSessionRecovery = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupToolbar(views.loginWebToolbar) + } + + override fun updateWithState(state: LoginViewState2) { + setupTitle(state) + + isForSessionRecovery = state.deviceId?.isNotBlank() == true + + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } + + private fun setupTitle(state: LoginViewState2) { + views.loginWebToolbar.title = when (state.signMode) { + SignMode2.SignIn -> getString(R.string.login_signin) + else -> getString(R.string.login_signup) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: LoginViewState2) { + views.loginWebWebView.settings.javaScriptEnabled = true + + // Enable local storage to support SSO with Firefox accounts + views.loginWebWebView.settings.domStorageEnabled = true + views.loginWebWebView.settings.databaseEnabled = true + + // Due to https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html, we hack + // the user agent to bypass the limitation of Google, as a quick fix (a proper solution will be to use the SSO SDK) + views.loginWebWebView.settings.userAgentString = "Mozilla/5.0 Google" + + // AppRTC requires third party cookies to work + val cookieManager = android.webkit.CookieManager.getInstance() + + // clear the cookies + if (cookieManager == null) { + launchWebView(state) + } else { + if (!cookieManager.hasCookies()) { + launchWebView(state) + } else { + try { + cookieManager.removeAllCookies { launchWebView(state) } + } catch (e: Exception) { + Timber.e(e, " cookieManager.removeAllCookie() fails") + launchWebView(state) + } + } + } + } + + private fun launchWebView(state: LoginViewState2) { + val url = loginViewModel.getFallbackUrl(state.signMode == SignMode2.SignIn, state.deviceId) ?: return + + views.loginWebWebView.loadUrl(url) + + views.loginWebWebView.webViewClient = object : WebViewClient() { + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, + error: SslError) { + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.ssl_could_not_verify) + .setPositiveButton(R.string.ssl_trust) { _, _ -> handler.proceed() } + .setNegativeButton(R.string.ssl_do_not_trust) { _, _ -> handler.cancel() } + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + handler.cancel() + dialog.dismiss() + return@OnKeyListener true + } + false + }) + .setCancelable(false) + .show() + } + + override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { + super.onReceivedError(view, errorCode, description, failingUrl) + + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnWebLoginError(errorCode, description, failingUrl))) + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + views.loginWebToolbar.subtitle = url + } + + override fun onPageFinished(view: WebView, url: String) { + // avoid infinite onPageFinished call + if (url.startsWith("http")) { + // Generic method to make a bridge between JS and the UIWebView + assetReader.readAssetFile("sendObject.js")?.let { view.loadUrl(it) } + + if (state.signMode == SignMode2.SignIn) { + // The function the fallback page calls when the login is complete + assetReader.readAssetFile("onLogin.js")?.let { view.loadUrl(it) } + } else { + // MODE_REGISTER + // The function the fallback page calls when the registration is complete + assetReader.readAssetFile("onRegistered.js")?.let { view.loadUrl(it) } + } + } + } + + /** + * Example of (formatted) url for MODE_LOGIN: + * + *
+             * js:{
+             *     "action":"onLogin",
+             *     "credentials":{
+             *         "user_id":"@user:matrix.org",
+             *         "access_token":"[ACCESS_TOKEN]",
+             *         "home_server":"matrix.org",
+             *         "device_id":"[DEVICE_ID]",
+             *         "well_known":{
+             *             "m.homeserver":{
+             *                 "base_url":"https://matrix.org/"
+             *                 }
+             *             }
+             *         }
+             *    }
+             * 
+ * @param view + * @param url + * @return + */ + override fun shouldOverrideUrlLoading(view: WebView, url: String?): Boolean { + if (url == null) return super.shouldOverrideUrlLoading(view, url as String?) + + if (url.startsWith("js:")) { + var json = url.substring(3) + var javascriptResponse: JavascriptResponse? = null + + try { + // URL decode + json = URLDecoder.decode(json, "UTF-8") + val adapter = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java) + javascriptResponse = adapter.fromJson(json) + } catch (e: Exception) { + Timber.e(e, "## shouldOverrideUrlLoading() : fromJson failed") + } + + // succeeds to parse parameters + if (javascriptResponse != null) { + val action = javascriptResponse.action + + if (state.signMode == SignMode2.SignIn) { + if (action == "onLogin") { + javascriptResponse.credentials?.let { notifyViewModel(it) } + } + } else { + // MODE_REGISTER + // check the required parameters + if (action == "onRegistered") { + javascriptResponse.credentials?.let { notifyViewModel(it) } + } + } + } + return true + } + + return super.shouldOverrideUrlLoading(view, url) + } + } + } + + private fun notifyViewModel(credentials: Credentials) { + if (isForSessionRecovery) { + val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + softLogoutViewModel.handle(SoftLogoutAction.WebLoginSuccess(credentials)) + } else { + loginViewModel.handle(LoginAction2.WebLoginSuccess(credentials)) + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignin) + } + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + return when { + toolbarButton -> super.onBackPressed(toolbarButton) + views.loginWebWebView.canGoBack() -> views.loginWebWebView.goBack().run { true } + else -> super.onBackPressed(toolbarButton) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/SignMode2.kt b/vector/src/main/java/im/vector/app/features/login2/SignMode2.kt new file mode 100644 index 0000000000..f3d59837e7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/SignMode2.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2 + +enum class SignMode2 { + Unknown, + + // Account creation + SignUp, + + // Login + SignIn +} diff --git a/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedAction.kt b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedAction.kt new file mode 100644 index 0000000000..f108bfa886 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedAction.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2.created + +import android.net.Uri +import im.vector.app.core.platform.VectorViewModelAction + +sealed class AccountCreatedAction : VectorViewModelAction { + data class SetDisplayName(val displayName: String) : AccountCreatedAction() + data class SetAvatar(val avatarUri: Uri, val filename: String) : AccountCreatedAction() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedFragment.kt b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedFragment.kt new file mode 100644 index 0000000000..861e3f9e98 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedFragment.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2.created + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper +import im.vector.app.core.intent.getFilenameFromUri +import im.vector.app.core.resources.ColorProvider +import im.vector.app.databinding.DialogBaseEditTextBinding +import im.vector.app.databinding.FragmentLoginAccountCreatedBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.login2.AbstractLoginFragment2 +import im.vector.app.features.login2.LoginAction2 +import im.vector.app.features.login2.LoginActivity2 +import im.vector.app.features.login2.LoginViewState2 +import org.matrix.android.sdk.api.util.MatrixItem +import java.util.UUID +import javax.inject.Inject + +/** + * In this screen: + * - the account has been created and we propose the user to set an avatar and a display name + */ +class AccountCreatedFragment @Inject constructor( + val accountCreatedViewModelFactory: AccountCreatedViewModel.Factory, + private val avatarRenderer: AvatarRenderer, + private val dateFormatter: VectorDateFormatter, + private val matrixItemColorProvider: MatrixItemColorProvider, + colorProvider: ColorProvider +) : AbstractLoginFragment2(), + GalleryOrCameraDialogHelper.Listener { + + private val viewModel: AccountCreatedViewModel by fragmentViewModel() + + private val galleryOrCameraDialogHelper = GalleryOrCameraDialogHelper(this, colorProvider) + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginAccountCreatedBinding { + return FragmentLoginAccountCreatedBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupClickListener() + setupSubmitButton() + observeViewEvents() + + viewModel.subscribe { invalidateState(it) } + + views.loginAccountCreatedTime.text = dateFormatter.format(System.currentTimeMillis(), DateFormatKind.MESSAGE_SIMPLE) + } + + private fun observeViewEvents() { + viewModel.observeViewEvents { + when (it) { + is AccountCreatedViewEvents.Failure -> displayErrorDialog(it.throwable) + } + } + } + + private fun setupClickListener() { + views.loginAccountCreatedMessage.setOnClickListener { + // Update display name + displayDialog() + } + views.loginAccountCreatedAvatar.setOnClickListener { + galleryOrCameraDialogHelper.show() + } + } + + private fun displayDialog() = withState(viewModel) { state -> + val inflater = requireActivity().layoutInflater + val layout = inflater.inflate(R.layout.dialog_base_edit_text, null) + val views = DialogBaseEditTextBinding.bind(layout) + views.editText.setText(state.currentUser()?.getBestName().orEmpty()) + + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.settings_display_name) + .setView(layout) + .setPositiveButton(R.string.ok) { _, _ -> + val newName = views.editText.text.toString() + viewModel.handle(AccountCreatedAction.SetDisplayName(newName)) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + override fun onImageReady(uri: Uri?) { + uri ?: return + viewModel.handle(AccountCreatedAction.SetAvatar( + avatarUri = uri, + filename = getFilenameFromUri(requireContext(), uri) ?: UUID.randomUUID().toString()) + ) + } + + private fun setupSubmitButton() { + views.loginAccountCreatedLater.setOnClickListener { terminate() } + views.loginAccountCreatedDone.setOnClickListener { terminate() } + } + + private fun terminate() { + loginViewModel.handle(LoginAction2.Finish) + } + + private fun invalidateState(state: AccountCreatedViewState) { + // Ugly hack... + (activity as? LoginActivity2)?.setIsLoading(state.isLoading) + + views.loginAccountCreatedSubtitle.text = getString(R.string.login_account_created_subtitle, state.userId) + + val user = state.currentUser() + if (user != null) { + avatarRenderer.render(user, views.loginAccountCreatedAvatar) + views.loginAccountCreatedMemberName.text = user.getBestName() + } else { + // Should not happen + views.loginAccountCreatedMemberName.text = state.userId + } + + // User color + views.loginAccountCreatedMemberName + .setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(state.userId))) + + views.loginAccountCreatedLater.isVisible = state.hasBeenModified.not() + views.loginAccountCreatedDone.isVisible = state.hasBeenModified + } + + override fun updateWithState(state: LoginViewState2) { + // No op + } + + override fun resetViewModel() { + // No op + } + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + // Just start the next Activity + terminate() + return false + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewEvents.kt b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewEvents.kt new file mode 100644 index 0000000000..4677e1abd5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package im.vector.app.features.login2.created + +import im.vector.app.core.platform.VectorViewEvents + +/** + * Transient events for Account Created + */ +sealed class AccountCreatedViewEvents : VectorViewEvents { + data class Failure(val throwable: Throwable) : AccountCreatedViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewModel.kt b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewModel.kt new file mode 100644 index 0000000000..1acec968b6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewModel.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2.created + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.MatrixPatterns +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.rx.rx +import org.matrix.android.sdk.rx.unwrap +import timber.log.Timber + +class AccountCreatedViewModel @AssistedInject constructor( + @Assisted initialState: AccountCreatedViewState, + private val session: Session +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: AccountCreatedViewState): AccountCreatedViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: AccountCreatedViewState): AccountCreatedViewModel? { + val fragment: AccountCreatedFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.accountCreatedViewModelFactory.create(state) + } + } + + init { + setState { + copy( + userId = session.myUserId + ) + } + observeUser() + } + + private fun observeUser() { + session.rx() + .liveUser(session.myUserId) + .unwrap() + .map { + if (MatrixPatterns.isUserId(it.userId)) { + it.toMatrixItem() + } else { + Timber.w("liveUser() has returned an invalid user: $it") + MatrixItem.UserItem(session.myUserId, null, null) + } + } + .execute { + copy(currentUser = it) + } + } + + override fun handle(action: AccountCreatedAction) { + when (action) { + is AccountCreatedAction.SetAvatar -> handleSetAvatar(action) + is AccountCreatedAction.SetDisplayName -> handleSetDisplayName(action) + } + } + + private fun handleSetAvatar(action: AccountCreatedAction.SetAvatar) { + setState { copy(isLoading = true) } + viewModelScope.launch { + val result = runCatching { session.updateAvatar(session.myUserId, action.avatarUri, action.filename) } + .onFailure { _viewEvents.post(AccountCreatedViewEvents.Failure(it)) } + setState { + copy( + isLoading = false, + hasBeenModified = hasBeenModified || result.isSuccess + ) + } + } + } + + private fun handleSetDisplayName(action: AccountCreatedAction.SetDisplayName) { + setState { copy(isLoading = true) } + viewModelScope.launch { + val result = runCatching { session.setDisplayName(session.myUserId, action.displayName) } + .onFailure { _viewEvents.post(AccountCreatedViewEvents.Failure(it)) } + setState { + copy( + isLoading = false, + hasBeenModified = hasBeenModified || result.isSuccess + ) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewState.kt b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewState.kt new file mode 100644 index 0000000000..80211b3da2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/created/AccountCreatedViewState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2.created + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.util.MatrixItem + +data class AccountCreatedViewState( + val userId: String = "", + val isLoading: Boolean = false, + val currentUser: Async = Uninitialized, + val hasBeenModified: Boolean = false +) : MvRxState diff --git a/vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt new file mode 100755 index 0000000000..0be696e1c8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.login2.terms + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.args +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.core.utils.openUrlInChromeCustomTab +import im.vector.app.databinding.FragmentLoginTerms2Binding +import im.vector.app.features.login.terms.LocalizedFlowDataLoginTermsChecked +import im.vector.app.features.login.terms.LoginTermsFragmentArgument +import im.vector.app.features.login.terms.LoginTermsViewState +import im.vector.app.features.login.terms.PolicyController +import im.vector.app.features.login2.AbstractLoginFragment2 +import im.vector.app.features.login2.LoginAction2 +import im.vector.app.features.login2.LoginViewState2 +import org.matrix.android.sdk.internal.auth.registration.LocalizedFlowDataLoginTerms +import javax.inject.Inject + +/** + * LoginTermsFragment displays the list of policies the user has to accept + */ +class LoginTermsFragment2 @Inject constructor( + private val policyController: PolicyController +) : AbstractLoginFragment2(), + PolicyController.PolicyControllerListener { + + private val params: LoginTermsFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginTerms2Binding { + return FragmentLoginTerms2Binding.inflate(inflater, container, false) + } + + private var loginTermsViewState: LoginTermsViewState = LoginTermsViewState(emptyList()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + views.loginTermsPolicyList.configureWith(policyController) + policyController.listener = this + + val list = ArrayList() + + params.localizedFlowDataLoginTerms + .forEach { + list.add(LocalizedFlowDataLoginTermsChecked(it)) + } + + loginTermsViewState = LoginTermsViewState(list) + } + + private fun setupViews() { + views.loginTermsSubmit.setOnClickListener { submit() } + } + + override fun onDestroyView() { + views.loginTermsPolicyList.cleanup() + policyController.listener = null + super.onDestroyView() + } + + private fun renderState() { + policyController.setData(loginTermsViewState.localizedFlowDataLoginTermsChecked) + + // Button is enabled only if all checkboxes are checked + views.loginTermsSubmit.isEnabled = loginTermsViewState.allChecked() + } + + override fun setChecked(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms, isChecked: Boolean) { + if (isChecked) { + loginTermsViewState.check(localizedFlowDataLoginTerms) + } else { + loginTermsViewState.uncheck(localizedFlowDataLoginTerms) + } + + renderState() + } + + override fun openPolicy(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms) { + localizedFlowDataLoginTerms.localizedUrl + ?.takeIf { it.isNotBlank() } + ?.let { + openUrlInChromeCustomTab(requireContext(), null, it) + } + } + + private fun submit() { + loginViewModel.handle(LoginAction2.AcceptTerms) + } + + override fun updateWithState(state: LoginViewState2) { + policyController.homeServer = state.homeServerUrlFromUser.toReducedUrl() + renderState() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignup) + } +} diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index bf8fa497ff..3abf01583c 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -54,6 +54,9 @@ import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.filtered.FilteredRoomsActivity import im.vector.app.features.invite.InviteUsersToRoomActivity +import im.vector.app.features.login.LoginActivity +import im.vector.app.features.login.LoginConfig +import im.vector.app.features.login2.LoginActivity2 import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.media.AttachmentData import im.vector.app.features.media.BigImageViewerActivity @@ -99,6 +102,16 @@ class DefaultNavigator @Inject constructor( private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider ) : Navigator { + override fun openLogin(context: Context, loginConfig: LoginConfig?, flags: Int) { + val intent = if (context.resources.getBoolean(R.bool.useLoginV2)) { + LoginActivity2.newIntent(context, loginConfig) + } else { + LoginActivity.newIntent(context, loginConfig) + } + intent.addFlags(flags) + context.startActivity(intent) + } + override fun openRoom(context: Context, roomId: String, eventId: String?, buildTask: Boolean) { if (sessionHolder.getSafeActiveSession()?.getRoom(roomId) == null) { fatalError("Trying to open an unknown room $roomId", vectorPreferences.failFast()) diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index 2302a749e7..444c48bddb 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -23,6 +23,7 @@ import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.core.util.Pair import im.vector.app.features.crypto.recover.SetupMode +import im.vector.app.features.login.LoginConfig import im.vector.app.features.media.AttachmentData import im.vector.app.features.pin.PinMode import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData @@ -36,6 +37,8 @@ import org.matrix.android.sdk.api.util.MatrixItem interface Navigator { + fun openLogin(context: Context, loginConfig: LoginConfig? = null, flags: Int = 0) + fun openRoom(context: Context, roomId: String, eventId: String? = null, buildTask: Boolean = false) sealed class PostSwitchSpaceAction { diff --git a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandlerActivity.kt b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandlerActivity.kt index 02c9c7f717..ee4e0e05b5 100644 --- a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandlerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandlerActivity.kt @@ -26,7 +26,6 @@ import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.FragmentProgressBinding import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.LoadingFragment -import im.vector.app.features.login.LoginActivity import javax.inject.Inject class PermalinkHandlerActivity : VectorBaseActivity() { @@ -71,9 +70,10 @@ class PermalinkHandlerActivity : VectorBaseActivity() { } private fun startLoginActivity() { - val intent = LoginActivity.newIntent(this, null) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + navigator.openLogin( + context = this, + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + ) finish() } } diff --git a/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt b/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt index f89480046f..7b5d8a9ebd 100644 --- a/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt +++ b/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt @@ -41,7 +41,6 @@ import im.vector.app.databinding.FragmentIncomingShareBinding import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs -import im.vector.app.features.login.LoginActivity import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -211,9 +210,10 @@ class IncomingShareFragment @Inject constructor( } private fun startLoginActivity() { - val intent = LoginActivity.newIntent(requireActivity(), null) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) + navigator.openLogin( + context = requireActivity(), + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + ) requireActivity().finish() } diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutActivity2.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutActivity2.kt new file mode 100644 index 0000000000..cfccc6f699 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutActivity2.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.signout.soft + +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.viewModel +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.extensions.replaceFragment +import im.vector.app.features.MainActivity +import im.vector.app.features.MainActivityArgs +import im.vector.app.features.login2.LoginActivity2 + +import org.matrix.android.sdk.api.failure.GlobalError +import org.matrix.android.sdk.api.session.Session +import timber.log.Timber +import javax.inject.Inject + +/** + * In this screen, the user is viewing a message informing that he has been logged out + * Extends LoginActivity to get the login with SSO and forget password functionality for (nearly) free + * + * This is just a copy of SoftLogoutActivity2, which extends LoginActivity2 + */ +class SoftLogoutActivity2 : LoginActivity2() { + + private val softLogoutViewModel: SoftLogoutViewModel by viewModel() + + @Inject lateinit var softLogoutViewModelFactory: SoftLogoutViewModel.Factory + @Inject lateinit var session: Session + @Inject lateinit var errorFormatter: ErrorFormatter + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + override fun initUiAndData() { + super.initUiAndData() + + softLogoutViewModel.subscribe(this) { + updateWithState(it) + } + + softLogoutViewModel.observeViewEvents { handleSoftLogoutViewEvents(it) } + } + + private fun handleSoftLogoutViewEvents(softLogoutViewEvents: SoftLogoutViewEvents) { + when (softLogoutViewEvents) { + is SoftLogoutViewEvents.Failure -> + showError(errorFormatter.toHumanReadable(softLogoutViewEvents.throwable)) + is SoftLogoutViewEvents.ErrorNotSameUser -> { + // Pop the backstack + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + // And inform the user + showError(getString( + R.string.soft_logout_sso_not_same_user_error, + softLogoutViewEvents.currentUserId, + softLogoutViewEvents.newUserId) + ) + } + is SoftLogoutViewEvents.ClearData -> { + MainActivity.restartApp(this, MainActivityArgs(clearCredentials = true)) + } + } + } + + private fun showError(message: String) { + AlertDialog.Builder(this) + .setTitle(R.string.dialog_title_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun addFirstFragment() { + replaceFragment(R.id.loginFragmentContainer, SoftLogoutFragment::class.java) + } + + private fun updateWithState(softLogoutViewState: SoftLogoutViewState) { + if (softLogoutViewState.asyncLoginAction is Success) { + MainActivity.restartApp(this, MainActivityArgs()) + } + + views.loginLoading.isVisible = softLogoutViewState.isLoading() + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, SoftLogoutActivity2::class.java) + } + } + + override fun handleInvalidToken(globalError: GlobalError.InvalidToken) { + // No op here + Timber.w("Ignoring invalid token global error") + } +} diff --git a/vector/src/main/res/layout/fragment_login_account_created.xml b/vector/src/main/res/layout/fragment_login_account_created.xml new file mode 100644 index 0000000000..c07a1f6a33 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_account_created.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_generic_text_input_form_2.xml b/vector/src/main/res/layout/fragment_login_generic_text_input_form_2.xml new file mode 100644 index 0000000000..1ae081fd88 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_generic_text_input_form_2.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_2.xml b/vector/src/main/res/layout/fragment_login_reset_password_2.xml new file mode 100644 index 0000000000..a103f9a5b7 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_2.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_mail_confirmation_2.xml b/vector/src/main/res/layout/fragment_login_reset_password_mail_confirmation_2.xml new file mode 100644 index 0000000000..afb5d88a25 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_mail_confirmation_2.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_success_2.xml b/vector/src/main/res/layout/fragment_login_reset_password_success_2.xml new file mode 100644 index 0000000000..626564325e --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_success_2.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_selection_2.xml b/vector/src/main/res/layout/fragment_login_server_selection_2.xml new file mode 100644 index 0000000000..9200a76f04 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_server_selection_2.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_url_form_2.xml b/vector/src/main/res/layout/fragment_login_server_url_form_2.xml new file mode 100644 index 0000000000..75b587f0c1 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_server_url_form_2.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_signin_password_2.xml b/vector/src/main/res/layout/fragment_login_signin_password_2.xml new file mode 100644 index 0000000000..f75706799c --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_signin_password_2.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_signin_to_any_2.xml b/vector/src/main/res/layout/fragment_login_signin_to_any_2.xml new file mode 100644 index 0000000000..3654c81f82 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_signin_to_any_2.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_signin_username_2.xml b/vector/src/main/res/layout/fragment_login_signin_username_2.xml new file mode 100644 index 0000000000..5521b52aab --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_signin_username_2.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_signup_password_2.xml b/vector/src/main/res/layout/fragment_login_signup_password_2.xml new file mode 100644 index 0000000000..83935c339f --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_signup_password_2.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_signup_username_2.xml b/vector/src/main/res/layout/fragment_login_signup_username_2.xml new file mode 100644 index 0000000000..8c9a7741d1 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_signup_username_2.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_splash_2.xml b/vector/src/main/res/layout/fragment_login_splash_2.xml new file mode 100644 index 0000000000..0b06d1cc65 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_splash_2.xml @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_sso_only_2.xml b/vector/src/main/res/layout/fragment_login_sso_only_2.xml new file mode 100644 index 0000000000..abcefbefce --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_sso_only_2.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_terms_2.xml b/vector/src/main/res/layout/fragment_login_terms_2.xml new file mode 100644 index 0000000000..d6ccfbba87 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_terms_2.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_wait_for_email_2.xml b/vector/src/main/res/layout/fragment_login_wait_for_email_2.xml new file mode 100644 index 0000000000..8b7f36bb44 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_wait_for_email_2.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_login_password_form.xml b/vector/src/main/res/layout/item_login_password_form.xml index d6b5d6898e..d8a2d96809 100644 --- a/vector/src/main/res/layout/item_login_password_form.xml +++ b/vector/src/main/res/layout/item_login_password_form.xml @@ -58,6 +58,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="start" + android:paddingStart="0dp" + android:paddingEnd="0dp" android:text="@string/auth_forgot_password" /> #70BF56 #ff4b55 + #ff812d #ff4b55 #2f9edb diff --git a/vector/src/main/res/values/strings_login_v2.xml b/vector/src/main/res/values/strings_login_v2.xml new file mode 100644 index 0000000000..5d1e14d73e --- /dev/null +++ b/vector/src/main/res/values/strings_login_v2.xml @@ -0,0 +1,50 @@ + + + + + + Welcome back %s! + Please enter your password + Please enter your Matrix identifier + Matrix identifiers start with @, for instance @alice:server.org + If you do not know your Matrix identifier, or if your account has been created using Single Sign On (for instance using a Google account), or if you want to connect using your simple name, or an email associated to your account, you have to select your server first. + Choose a server + Please choose a server + Please select your server + Please choose a password + Please choose a new password + Your Matrix identifier + Press back to change + Choose a password + Enter an email associated to your Matrix account + Choose a new password + Please choose an identifier + Your identifier will be used to connect to your Matrix account + Once your account is created, your identifier cannot be modified. However you will be able to change your display name. + If you\'re not sure, select this option + Element Matrix Server and others + Create a new account + I already have an account + + Warning: no profile information can be retrieved with this Matrix identifier. Please check that there is no mistake. + + We just sent an email to %1$s. + Click on the link it contains to continue the account creation. + Congratulations! + You account %s has been successfully created. + To complete your profile, you can set a profile image and/or a display name. This can also be done later from the settings. + This is how your messages will appear: + Hello Matrix world! + Click on the image and on your name to configure them. + + Associate an email + Associate an email to be able to later recover your account, in case you forget your password. + The server %s requires you to associate an email to create an account. + + Associate a phone number + Associate a phone number to optionally allow people you know to discover you. + The server %s requires you to associate a phone number to create an account. + + %s about Element Matrix Service. + + \ No newline at end of file