diff --git a/CHANGES.md b/CHANGES.md index 7bbeb0443d..62bd92006e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,11 +7,13 @@ Features ✨: - Url preview (#481) - Store encrypted file in cache and cleanup decrypted file at each app start (#2512) - Emoji Keyboard (#2520) + - Social login (#2452) Improvements 🙌: - Add Setting Item to Change PIN (#2462) - Improve room history visibility setting UX (#1579) - Matrix.to deeplink custom scheme support + - Homeserver history (#1933) Bugfix 🐛: - Fix cancellation of sending event (#2438) diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt index 0d71af864b..03943cea14 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt @@ -25,6 +25,7 @@ import androidx.work.WorkManager import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.common.DaggerTestMatrixComponent @@ -49,6 +50,7 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo @Inject internal lateinit var backgroundDetectionObserver: BackgroundDetectionObserver @Inject internal lateinit var olmManager: OlmManager @Inject internal lateinit var sessionManager: SessionManager + @Inject internal lateinit var homeServerHistoryService: HomeServerHistoryService private val uiHandler = Handler(Looper.getMainLooper()) @@ -71,6 +73,8 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo fun rawService() = rawService + fun homeServerHistoryService() = homeServerHistoryService + fun legacySessionImporter(): LegacySessionImporter { return legacySessionImporter } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt index cf6f37cec8..a5d457222f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt @@ -23,6 +23,7 @@ import androidx.work.WorkManager import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.internal.SessionManager @@ -47,6 +48,7 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo @Inject internal lateinit var backgroundDetectionObserver: BackgroundDetectionObserver @Inject internal lateinit var olmManager: OlmManager @Inject internal lateinit var sessionManager: SessionManager + @Inject internal lateinit var homeServerHistoryService: HomeServerHistoryService init { Monarchy.init(context) @@ -65,6 +67,8 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo fun rawService() = rawService + fun homeServerHistoryService() = homeServerHistoryService + fun legacySessionImporter(): LegacySessionImporter { return legacySessionImporter } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/Constants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/Constants.kt index 6cacf55a38..871c2559f8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/Constants.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/Constants.kt @@ -19,7 +19,7 @@ package org.matrix.android.sdk.api.auth /** * Path to use when the client does not supported any or all login flows * Ref: https://matrix.org/docs/spec/client_server/latest#login-fallback - * */ + */ const val LOGIN_FALLBACK_PATH = "/_matrix/static/client/login/" /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/HomeServerHistoryService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/HomeServerHistoryService.kt new file mode 100644 index 0000000000..77e33b8934 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/HomeServerHistoryService.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 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 + +/** + * A simple service to remember homeservers you already connected to. + */ +interface HomeServerHistoryService { + + fun getKnownServersUrls(): List + + fun addHomeServerToHistory(url: String) + + fun clearHistory() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt index 64d3ddcca5..f1f9ba3916 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.api.auth.data sealed class LoginFlowResult { data class Success( val supportedLoginTypes: List, + val ssoIdentityProviders: List?, val isLoginAndRegistrationSupported: Boolean, val homeServerUrl: String, val isOutdatedHomeserver: Boolean 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 new file mode 100644 index 0000000000..d89607843f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SsoIdentityProvider.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2020 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.data + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.android.parcel.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class SsoIdentityProvider( + /** + * The id field would be opaque with the accepted characters matching unreserved URI characters as defined in RFC3986 + * - this was chosen to avoid having to encode special characters in the URL. Max length 128. + */ + @Json(name = "id") val id: String, + /** + * The name field should be the human readable string intended for printing by the client. + */ + @Json(name = "name") val name: String?, + /** + * The icon field is the only optional field and should point to an icon representing the IdP. + * If present then it must be an HTTPS URL to an image resource. + * This should be hosted by the homeserver service provider to not leak the client's IP address unnecessarily. + */ + @Json(name = "icon") val iconUrl: String? +) : Parcelable { + + companion object { + // Not really defined by the spec, but we may define some ids here + const val ID_GOOGLE = "google" + const val ID_GITHUB = "github" + const val ID_APPLE = "apple" + const val ID_FACEBOOK = "facebook" + const val ID_TWITTER = "twitter" + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt index e55546e12d..757a09fb3f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt @@ -36,7 +36,7 @@ sealed class CallState { * Connected. Incoming/Outgoing call, ice layer connecting or connected * Notice that the PeerState failed is not always final, if you switch network, new ice candidtates * could be exchanged, and the connection could go back to connected - * */ + */ data class Connected(val iceConnectionState: PeerConnection.PeerConnectionState) : CallState() /** Terminated. Incoming/Outgoing call, the call is terminated */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt index c6d610188e..2ec8900f7c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.internal.di.AuthDatabase import org.matrix.android.sdk.internal.legacy.DefaultLegacySessionImporter import org.matrix.android.sdk.internal.wellknown.WellknownModule import io.realm.RealmConfiguration +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import java.io.File @Module(includes = [WellknownModule::class]) @@ -80,4 +81,7 @@ internal abstract class AuthModule { @Binds abstract fun bindDirectLoginTask(task: DefaultDirectLoginTask): DirectLoginTask + + @Binds + abstract fun bindHomeServerHistoryService(service: DefaultHomeServerHistoryService): HomeServerHistoryService } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index 3d5a0efcd4..55f053de8d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.LoginFlowResult +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.RegistrationWizard import org.matrix.android.sdk.api.auth.wellknown.WellknownResult @@ -278,6 +279,7 @@ internal class DefaultAuthenticationService @Inject constructor( } return LoginFlowResult.Success( loginFlowResponse.flows.orEmpty().mapNotNull { it.type }, + loginFlowResponse.flows.orEmpty().firstOrNull { it.type == LoginFlowTypes.SSO }?.ssoIdentityProvider, versions.isLoginAndRegistrationSupportedBySdk(), homeServerUrl, !versions.isSupportedBySdk() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultHomeServerHistoryService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultHomeServerHistoryService.kt new file mode 100644 index 0000000000..7415938ebc --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultHomeServerHistoryService.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020 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 + +import com.zhuinden.monarchy.Monarchy +import io.realm.kotlin.where +import org.matrix.android.sdk.api.auth.HomeServerHistoryService +import org.matrix.android.sdk.internal.database.model.KnownServerUrlEntity +import org.matrix.android.sdk.internal.di.GlobalDatabase +import javax.inject.Inject + +class DefaultHomeServerHistoryService @Inject constructor( + @GlobalDatabase private val monarchy: Monarchy +) : HomeServerHistoryService { + + override fun getKnownServersUrls(): List { + return monarchy.fetchAllMappedSync( + { realm -> + realm.where() + }, + { it.url } + ) + } + + override fun addHomeServerToHistory(url: String) { + monarchy.writeAsync { realm -> + KnownServerUrlEntity(url).let { + realm.insertOrUpdate(it) + } + } + } + + override fun clearHistory() { + monarchy.runTransactionSync { it.where().findAll().deleteAllFromRealm() } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt index 8acdee3608..c333b3524e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.auth.data import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider @JsonClass(generateAdapter = true) internal data class LoginFlowResponse( @@ -34,5 +35,13 @@ internal data class LoginFlow( * The login type. This is supplied as the type when logging in. */ @Json(name = "type") - val type: String? + val type: String?, + + /** + * Augments m.login.sso flow discovery definition to include metadata on the supported IDPs + * the client can show a button for each of the supported providers + * See MSC #2858 + */ + @Json(name = "identity_providers") + val ssoIdentityProvider: List? ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/RegistrationFlowResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/RegistrationFlowResponse.kt index 5b105c4d40..3461a4d738 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/RegistrationFlowResponse.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/RegistrationFlowResponse.kt @@ -51,6 +51,18 @@ data class RegistrationFlowResponse( * The information that the client will need to know in order to use a given type of authentication. * For each login stage type presented, that type may be present as a key in this dictionary. * For example, the public key of reCAPTCHA stage could be given here. + * other example + * "params": { + * "m.login.sso": { + * "identity_providers": [ + * { + * "id": "google", + * "name": "Google", + * "icon": "https://..." + * } + * ] + * } + * } */ @Json(name = "params") val params: JsonDict? = null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/KnownServerUrlEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/KnownServerUrlEntity.kt new file mode 100644 index 0000000000..1ebdc22ab4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/KnownServerUrlEntity.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2020 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.database.model + +import io.realm.RealmObject +import io.realm.annotations.PrimaryKey + +internal open class KnownServerUrlEntity( + @PrimaryKey + var url: String = "" +) : RealmObject() { + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt index f959104e11..9d6fa29bb2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MatrixComponent.kt @@ -25,6 +25,7 @@ import okhttp3.OkHttpClient import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.auth.AuthModule @@ -62,6 +63,8 @@ internal interface MatrixComponent { fun rawService(): RawService + fun homeServerHistoryService(): HomeServerHistoryService + fun context(): Context fun matrixConfiguration(): MatrixConfiguration diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt new file mode 100644 index 0000000000..49bcc72181 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmMigration.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2020 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.raw + +import io.realm.DynamicRealm +import io.realm.RealmMigration +import org.matrix.android.sdk.internal.database.model.KnownServerUrlEntityFields +import timber.log.Timber + +internal object GlobalRealmMigration : RealmMigration { + + // Current schema version + const val SCHEMA_VERSION = 1L + + override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { + Timber.d("Migrating Auth Realm from $oldVersion to $newVersion") + + if (oldVersion <= 0) migrateTo1(realm) + } + + private fun migrateTo1(realm: DynamicRealm) { + realm.schema.create("KnownServerUrlEntity") + .addField(KnownServerUrlEntityFields.URL, String::class.java) + .addPrimaryKey(KnownServerUrlEntityFields.URL) + .setRequired(KnownServerUrlEntityFields.URL, true) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmModule.kt index e4e4160193..770a49c904 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/GlobalRealmModule.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.raw import io.realm.annotations.RealmModule +import org.matrix.android.sdk.internal.database.model.KnownServerUrlEntity import org.matrix.android.sdk.internal.database.model.RawCacheEntity /** @@ -24,6 +25,7 @@ import org.matrix.android.sdk.internal.database.model.RawCacheEntity */ @RealmModule(library = true, classes = [ - RawCacheEntity::class + RawCacheEntity::class, + KnownServerUrlEntity::class ]) internal class GlobalRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt index aee2a52818..50721b809a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/raw/RawModule.kt @@ -57,6 +57,9 @@ internal abstract class RawModule { realmKeysUtils.configureEncryption(this, DB_ALIAS) } .name("matrix-sdk-global.realm") + .schemaVersion(GlobalRealmMigration.SCHEMA_VERSION) + .migration(GlobalRealmMigration) + .allowWritesOnUiThread(true) .modules(GlobalRealmModule()) .build() } 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 188ca32559..87ab875746 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 @@ -64,7 +64,6 @@ import im.vector.app.features.login.LoginResetPasswordSuccessFragment import im.vector.app.features.login.LoginServerSelectionFragment import im.vector.app.features.login.LoginServerUrlFormFragment import im.vector.app.features.login.LoginSignUpSignInSelectionFragment -import im.vector.app.features.login.LoginSignUpSignInSsoFragment import im.vector.app.features.login.LoginSplashFragment import im.vector.app.features.login.LoginWaitForEmailFragment import im.vector.app.features.login.LoginWebFragment @@ -230,11 +229,6 @@ interface FragmentModule { @FragmentKey(LoginSignUpSignInSelectionFragment::class) fun bindLoginSignUpSignInSelectionFragment(fragment: LoginSignUpSignInSelectionFragment): Fragment - @Binds - @IntoMap - @FragmentKey(LoginSignUpSignInSsoFragment::class) - fun bindLoginSignUpSignInSsoFragment(fragment: LoginSignUpSignInSsoFragment): Fragment - @Binds @IntoMap @FragmentKey(LoginSplashFragment::class) diff --git a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt index 28f3a52efa..273a142ff1 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt @@ -59,6 +59,7 @@ import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.ui.UiStateRepository import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session import javax.inject.Singleton @@ -127,6 +128,8 @@ interface VectorComponent { fun rawService(): RawService + fun homeServerHistoryService(): HomeServerHistoryService + fun bugReporter(): BugReporter fun vectorUncaughtExceptionHandler(): VectorUncaughtExceptionHandler diff --git a/vector/src/main/java/im/vector/app/core/di/VectorModule.kt b/vector/src/main/java/im/vector/app/core/di/VectorModule.kt index 1d7cd33241..77cad0ae73 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorModule.kt @@ -33,6 +33,7 @@ import im.vector.app.features.ui.SharedPreferencesUiStateRepository import im.vector.app.features.ui.UiStateRepository import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session @@ -85,6 +86,12 @@ abstract class VectorModule { fun providesRawService(matrix: Matrix): RawService { return matrix.rawService() } + + @Provides + @JvmStatic + fun providesHomeServerHistoryService(matrix: Matrix): HomeServerHistoryService { + return matrix.homeServerHistoryService() + } } @Binds diff --git a/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt b/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt new file mode 100644 index 0000000000..9b0a154100 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt @@ -0,0 +1,93 @@ +/* + * 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.login + +import android.content.ComponentName +import android.net.Uri +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import com.airbnb.mvrx.withState +import im.vector.app.core.utils.openUrlInChromeCustomTab + +abstract class AbstractSSOLoginFragment : AbstractLoginFragment() { + + // 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) + } + + 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) + prefetchUrl(state.getSsoUrl(null)) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login/LoginAction.kt b/vector/src/main/java/im/vector/app/features/login/LoginAction.kt index eb5aa86b3b..2b4e3d6be0 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginAction.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginAction.kt @@ -18,6 +18,7 @@ package im.vector.app.features.login import im.vector.app.core.platform.VectorViewModelAction 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 @@ -59,8 +60,13 @@ sealed class LoginAction : VectorViewModelAction { object ResetLogin : ResetAction() object ResetResetPassword : ResetAction() + // Homeserver history + object ClearHomeServerHistory : LoginAction() + // For the soft logout case - data class SetupSsoForSessionRecovery(val homeServerUrl: String, val deviceId: String) : LoginAction() + data class SetupSsoForSessionRecovery(val homeServerUrl: String, + val deviceId: String, + val ssoIdentityProviders: List?) : LoginAction() data class PostViewEvent(val viewEvent: LoginViewEvents) : LoginAction() diff --git a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt index 01e835b4e3..2e60fea660 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt @@ -157,11 +157,7 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable, UnlockedAc is LoginViewEvents.OnSignModeSelected -> onSignModeSelected(loginViewEvents) is LoginViewEvents.OnLoginFlowRetrieved -> addFragmentToBackstack(R.id.loginFragmentContainer, - if (loginViewEvents.isSso) { - LoginSignUpSignInSsoFragment::class.java - } else { - LoginSignUpSignInSelectionFragment::class.java - }, + LoginSignUpSignInSelectionFragment::class.java, option = commonOption) is LoginViewEvents.OnWebLoginError -> onWebLoginError(loginViewEvents) is LoginViewEvents.OnForgetPasswordClicked -> @@ -252,7 +248,8 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable, UnlockedAc // It depends on the LoginMode when (state.loginMode) { LoginMode.Unknown, - LoginMode.Sso -> error("Developer error") + is LoginMode.Sso -> error("Developer error") + is LoginMode.SsoAndPassword, LoginMode.Password -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginFragment::class.java, tag = FRAGMENT_LOGIN_TAG, diff --git a/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt index 3ee0d6d9df..7d98f1f8ee 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt @@ -37,6 +37,7 @@ import io.reactivex.Observable import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy import kotlinx.android.synthetic.main.fragment_login.* +import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.* import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.isInvalidPassword @@ -50,7 +51,7 @@ import javax.inject.Inject * In signup mode: * - the user is asked for login and password */ -class LoginFragment @Inject constructor() : AbstractLoginFragment() { +class LoginFragment @Inject constructor() : AbstractSSOLoginFragment() { private var passwordShown = false private var isSignupMode = false @@ -83,11 +84,13 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() { SignMode.SignUp -> { loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + loginSocialLoginButtons.mode = SocialLoginButtonsView.Mode.MODE_SIGN_UP } SignMode.SignIn, SignMode.SignInWithMatrixId -> { loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) + loginSocialLoginButtons.mode = SocialLoginButtonsView.Mode.MODE_SIGN_IN } }.exhaustive } @@ -169,6 +172,19 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() { ServerType.Unknown -> Unit /* Should not happen */ } loginPasswordNotice.isVisible = false + + if (state.loginMode is LoginMode.SsoAndPassword) { + loginSocialLoginContainer.isVisible = true + loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders + loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + openInCustomTab(state.getSsoUrl(id)) + } + } + } else { + loginSocialLoginContainer.isVisible = false + loginSocialLoginButtons.ssoIdentityProviders = null + } } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginMode.kt b/vector/src/main/java/im/vector/app/features/login/LoginMode.kt index 9a930dcd86..14accefc00 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginMode.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginMode.kt @@ -16,9 +16,31 @@ package im.vector.app.features.login -enum class LoginMode { - Unknown, - Password, - Sso, - Unsupported +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider + +sealed class LoginMode : Parcelable +/** because persist state */ { + @Parcelize object Unknown : LoginMode() + @Parcelize object Password : LoginMode() + @Parcelize data class Sso(val ssoIdentityProviders: List?) : LoginMode() + @Parcelize data class SsoAndPassword(val ssoIdentityProviders: List?) : LoginMode() + @Parcelize object Unsupported : LoginMode() +} + +fun LoginMode.ssoIdentityProviders() : List? { + return when (this) { + is LoginMode.Sso -> ssoIdentityProviders + is LoginMode.SsoAndPassword -> ssoIdentityProviders + else -> null + } +} + +fun LoginMode.hasSso() : Boolean { + return when (this) { + is LoginMode.Sso -> true + is LoginMode.SsoAndPassword -> true + else -> false + } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt index f4595de634..c9460e1a41 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt @@ -83,7 +83,7 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment if (state.loginMode != LoginMode.Unknown) { // LoginFlow for matrix.org has been retrieved - loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved(state.loginMode == LoginMode.Sso))) + loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved)) } } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt index af959fecd4..1915cdd204 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt @@ -20,9 +20,13 @@ import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import androidx.core.view.isInvisible import androidx.core.view.isVisible import butterknife.OnClick +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 @@ -55,6 +59,7 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment() loginServerUrlFormHomeServerUrl.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE) { + loginServerUrlFormHomeServerUrl.dismissDropDown() submit() return@setOnEditorActionListener true } @@ -81,6 +86,15 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment() loginServerUrlFormNotice.text = getString(R.string.login_server_url_form_common_notice) } } + val completions = state.knownCustomHomeServersUrls + if (BuildConfig.DEBUG) listOf("http://10.0.2.2:8080") else emptyList() + loginServerUrlFormHomeServerUrl.setAdapter(ArrayAdapter( + requireContext(), + R.layout.item_completion_homeserver, + completions + )) + loginServerUrlFormHomeServerUrlTil.endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU + .takeIf { completions.isNotEmpty() } + ?: TextInputLayout.END_ICON_NONE } @OnClick(R.id.loginServerUrlFormLearnMore) @@ -88,6 +102,11 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment() openUrlInChromeCustomTab(requireActivity(), null, EMS_LINK) } + @OnClick(R.id.loginServerUrlFormClearHistory) + fun clearHistory() { + loginViewModel.handle(LoginAction.ClearHomeServerHistory) + } + override fun resetViewModel() { loginViewModel.handle(LoginAction.ResetHomeServerUrl) } @@ -105,7 +124,7 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment() loginServerUrlFormHomeServerUrlTil.error = getString(R.string.login_error_invalid_home_server) } else -> { - loginServerUrlFormHomeServerUrl.setText(serverUrl) + loginServerUrlFormHomeServerUrl.setText(serverUrl, false /* to avoid completion dialog flicker*/) loginViewModel.handle(LoginAction.UpdateHomeServer(serverUrl)) } } @@ -129,9 +148,11 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment() override fun updateWithState(state: LoginViewState) { setupUi(state) + loginServerUrlFormClearHistory.isInvisible = state.knownCustomHomeServersUrls.isEmpty() + if (state.loginMode != LoginMode.Unknown) { // The home server url is valid - loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved(state.loginMode == LoginMode.Sso))) + loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved)) } } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt index fa2f6b9df8..ec931f89a2 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt @@ -18,6 +18,7 @@ package im.vector.app.features.login import androidx.core.view.isVisible import butterknife.OnClick +import com.airbnb.mvrx.withState import im.vector.app.R import im.vector.app.core.extensions.toReducedUrl import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.* @@ -26,11 +27,11 @@ import javax.inject.Inject /** * In this screen, the user is asked to sign up or to sign in to the homeserver */ -open class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFragment() { +class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractSSOLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_signup_signin_selection - protected fun setupUi(state: LoginViewState) { + private fun setupUi(state: LoginViewState) { when (state.serverType) { ServerType.MatrixOrg -> { loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) @@ -38,29 +39,61 @@ open class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLo loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrl.toReducedUrl()) loginSignupSigninText.text = getString(R.string.login_server_matrix_org_text) } - ServerType.EMS -> { + ServerType.EMS -> { loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_element_matrix_services) loginSignupSigninServerIcon.isVisible = true loginSignupSigninTitle.text = getString(R.string.login_connect_to_modular) loginSignupSigninText.text = state.homeServerUrl.toReducedUrl() } - ServerType.Other -> { + ServerType.Other -> { loginSignupSigninServerIcon.isVisible = false loginSignupSigninTitle.text = getString(R.string.login_server_other_title) loginSignupSigninText.text = getString(R.string.login_connect_to, state.homeServerUrl.toReducedUrl()) } - ServerType.Unknown -> Unit /* Should not happen */ + ServerType.Unknown -> Unit /* Should not happen */ + } + + when (state.loginMode) { + is LoginMode.SsoAndPassword -> { + loginSignupSigninSignInSocialLoginContainer.isVisible = true + loginSignupSigninSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders() + loginSignupSigninSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + val url = withState(loginViewModel) { it.getSsoUrl(id) } + openInCustomTab(url) + } + } + } + else -> { + // SSO only is managed without container as well as No sso + loginSignupSigninSignInSocialLoginContainer.isVisible = false + loginSignupSigninSocialLoginButtons.ssoIdentityProviders = null + } } } - private fun setupButtons() { - loginSignupSigninSubmit.text = getString(R.string.login_signup) - loginSignupSigninSignIn.isVisible = true + private fun setupButtons(state: LoginViewState) { + when (state.loginMode) { + is LoginMode.Sso -> { + // change to only one button that is sign in with sso + loginSignupSigninSubmit.text = getString(R.string.login_signin_sso) + loginSignupSigninSignIn.isVisible = false + } + else -> { + loginSignupSigninSubmit.text = getString(R.string.login_signup) + loginSignupSigninSignIn.isVisible = true + } + } } @OnClick(R.id.loginSignupSigninSubmit) - open fun submit() { - loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp)) + fun submit() = withState(loginViewModel) { state -> + if (state.loginMode is LoginMode.Sso) { + openInCustomTab(state.getSsoUrl(null)) + } else { + loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp)) + } + Unit } @OnClick(R.id.loginSignupSigninSignIn) @@ -74,6 +107,6 @@ open class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLo override fun updateWithState(state: LoginViewState) { setupUi(state) - setupButtons() + setupButtons(state) } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSsoFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSsoFragment.kt deleted file mode 100644 index 8d33ec2e27..0000000000 --- a/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSsoFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * 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.login - -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.core.view.isVisible -import im.vector.app.R -import im.vector.app.core.utils.openUrlInChromeCustomTab -import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.* -import javax.inject.Inject - -/** - * In this screen, the user is asked to sign up or to sign in using SSO - * This Fragment binds a CustomTabsServiceConnection if available, then prefetch the SSO url, as it will be likely to be opened. - */ -open class LoginSignUpSignInSsoFragment @Inject constructor() : LoginSignUpSignInSelectionFragment() { - - private var ssoUrl: String? = null - private var customTabsServiceConnection: CustomTabsServiceConnection? = null - private var customTabsClient: CustomTabsClient? = null - private var customTabsSession: CustomTabsSession? = null - - override fun onStart() { - super.onStart() - - 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) } - } - - override fun onServiceDisconnected(name: ComponentName?) { - } - } - .also { - CustomTabsClient.bindCustomTabsService( - requireContext(), - // Despite the API, packageName cannot be null - packageName, - it - ) - } - } - } - - private fun prefetchUrl(url: String) { - if (ssoUrl != null) return - - ssoUrl = url - if (customTabsSession == null) { - customTabsSession = customTabsClient?.newSession(null) - } - - customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null) - } - - override fun onStop() { - super.onStop() - customTabsServiceConnection?.let { requireContext().unbindService(it) } - customTabsServiceConnection = null - } - - private fun setupButtons() { - loginSignupSigninSubmit.text = getString(R.string.login_signin_sso) - loginSignupSigninSignIn.isVisible = false - } - - override fun submit() { - ssoUrl?.let { openUrlInChromeCustomTab(requireContext(), customTabsSession, it) } - } - - override fun updateWithState(state: LoginViewState) { - setupUi(state) - setupButtons() - prefetchUrl(state.getSsoUrl()) - } -} diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewEvents.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewEvents.kt index 3bc2948f89..dc14a0091d 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginViewEvents.kt @@ -34,7 +34,7 @@ sealed class LoginViewEvents : VectorViewEvents { object OpenServerSelection : LoginViewEvents() data class OnServerSelectionDone(val serverType: ServerType) : LoginViewEvents() - data class OnLoginFlowRetrieved(val isSso: Boolean) : LoginViewEvents() + object OnLoginFlowRetrieved : LoginViewEvents() data class OnSignModeSelected(val signMode: SignMode) : LoginViewEvents() object OnForgetPasswordClicked : LoginViewEvents() object OnResetPasswordSendThreePidDone : LoginViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt index 1f47916538..0a6dbcaae2 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt @@ -38,6 +38,7 @@ import im.vector.app.core.utils.ensureTrailingSlash import im.vector.app.features.signout.soft.SoftLogoutActivity import org.matrix.android.sdk.api.MatrixCallback 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.LoginFlowResult import org.matrix.android.sdk.api.auth.data.LoginFlowTypes @@ -63,7 +64,8 @@ class LoginViewModel @AssistedInject constructor( private val activeSessionHolder: ActiveSessionHolder, private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory, private val reAuthHelper: ReAuthHelper, - private val stringProvider: StringProvider + private val stringProvider: StringProvider, + private val homeServerHistoryService: HomeServerHistoryService ) : VectorViewModel(initialState) { @AssistedInject.Factory @@ -71,6 +73,16 @@ class LoginViewModel @AssistedInject constructor( fun create(initialState: LoginViewState): LoginViewModel } + init { + getKnownCustomHomeServersUrls() + } + + private fun getKnownCustomHomeServersUrls() { + setState { + copy(knownCustomHomeServersUrls = homeServerHistoryService.getKnownServersUrls()) + } + } + companion object : MvRxViewModelFactory { @JvmStatic @@ -121,6 +133,7 @@ class LoginViewModel @AssistedInject constructor( is LoginAction.ResetAction -> handleResetAction(action) is LoginAction.SetupSsoForSessionRecovery -> handleSetupSsoForSessionRecovery(action) is LoginAction.UserAcceptCertificate -> handleUserAcceptCertificate(action) + LoginAction.ClearHomeServerHistory -> handleClearHomeServerHistory() is LoginAction.PostViewEvent -> _viewEvents.post(action.viewEvent) }.exhaustive } @@ -129,10 +142,11 @@ class LoginViewModel @AssistedInject constructor( // It happen 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 LoginAction.UpdateHomeServer -> + is LoginAction.UpdateHomeServer -> { currentHomeServerConnectionConfig ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } ?.let { getLoginFlow(it) } + } is LoginAction.LoginOrRegister -> handleDirectLogin( finalLastAction, @@ -145,6 +159,16 @@ class LoginViewModel @AssistedInject constructor( } } + private fun rememberHomeServer(homeServerUrl: String) { + homeServerHistoryService.addHomeServerToHistory(homeServerUrl) + getKnownCustomHomeServersUrls() + } + + private fun handleClearHomeServerHistory() { + homeServerHistoryService.clearHistory() + getKnownCustomHomeServersUrls() + } + private fun handleLoginWithToken(action: LoginAction.LoginWithToken) { val safeLoginWizard = loginWizard @@ -184,7 +208,7 @@ class LoginViewModel @AssistedInject constructor( setState { copy( signMode = SignMode.SignIn, - loginMode = LoginMode.Sso, + loginMode = LoginMode.Sso(action.ssoIdentityProviders), homeServerUrl = action.homeServerUrl, deviceId = action.deviceId ) @@ -713,7 +737,6 @@ class LoginViewModel @AssistedInject constructor( private fun handleUpdateHomeserver(action: LoginAction.UpdateHomeServer) { val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) - if (homeServerConnectionConfig == null) { // This is invalid _viewEvents.post(LoginViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) @@ -751,13 +774,19 @@ class LoginViewModel @AssistedInject constructor( } override fun onSuccess(data: LoginFlowResult) { + // Valid Homeserver, add it to the history. + // Note: we add what the user has input, data.homeServerUrl can be different + rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString()) + when (data) { is LoginFlowResult.Success -> { val loginMode = when { // SSO login is taken first - data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso - data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password - else -> LoginMode.Unsupported + 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 } // FIXME We should post a view event here normally? diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt index 194ed668bc..383fd4a54e 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt @@ -51,7 +51,8 @@ data class LoginViewState( val loginMode: LoginMode = LoginMode.Unknown, @PersistState // Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable - val loginModeSupportedTypes: List = emptyList() + val loginModeSupportedTypes: List = emptyList(), + val knownCustomHomeServersUrls: List = emptyList() ) : MvRxState { fun isLoading(): Boolean { @@ -68,10 +69,13 @@ data class LoginViewState( return asyncLoginAction is Success } - fun getSsoUrl(): String { + fun getSsoUrl(providerId: String?): String { return buildString { append(homeServerUrl?.trim { it == '/' }) append(SSO_REDIRECT_PATH) + if (providerId != null) { + append("/$providerId") + } // Set a redirect url we will intercept later appendParamToUrl(SSO_REDIRECT_URL_PARAM, VECTOR_REDIRECT_URL) deviceId?.takeIf { it.isNotBlank() }?.let { diff --git a/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt b/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt new file mode 100644 index 0000000000..9290479a7a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt @@ -0,0 +1,157 @@ +/* + * 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.login + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import androidx.core.view.children +import com.google.android.material.button.MaterialButton +import im.vector.app.R +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider + +class SocialLoginButtonsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) + : LinearLayout(context, attrs, defStyle) { + + interface InteractionListener { + fun onProviderSelected(id: String?) + } + + enum class Mode { + MODE_SIGN_IN, + MODE_SIGN_UP, + MODE_CONTINUE, + } + + var ssoIdentityProviders: List? = null + set(newProviders) { + if (newProviders != ssoIdentityProviders) { + field = newProviders + update() + } + } + + var mode: Mode = Mode.MODE_CONTINUE + set(value) { + if (value != mode) { + field = value + update() + } + } + + var listener: InteractionListener? = null + + private fun update() { + val cachedViews = emptyMap().toMutableMap() + children.filterIsInstance().forEach { + cachedViews[it.getTag(R.id.loginSignupSigninSocialLoginButtons)?.toString() ?: ""] = it + } + removeAllViews() + if (ssoIdentityProviders.isNullOrEmpty()) { + // Put a default sign in with sso button + MaterialButton(context, null, R.attr.materialButtonOutlinedStyle).apply { + transformationMethod = null + textAlignment = View.TEXT_ALIGNMENT_CENTER + }.let { + it.text = getButtonTitle(context.getString(R.string.login_social_sso)) + it.textAlignment = View.TEXT_ALIGNMENT_CENTER + it.setOnClickListener { + listener?.onProviderSelected(null) + } + addView(it) + } + return + } + + ssoIdentityProviders?.forEach { identityProvider -> + // Use some heuristic to render buttons according to branding guidelines + val button: MaterialButton = cachedViews[identityProvider.id] + ?: when (identityProvider.id) { + SsoIdentityProvider.ID_GOOGLE -> { + MaterialButton(context, null, R.attr.vctr_social_login_button_google_style) + } + SsoIdentityProvider.ID_GITHUB -> { + MaterialButton(context, null, R.attr.vctr_social_login_button_github_style) + } + SsoIdentityProvider.ID_APPLE -> { + MaterialButton(context, null, R.attr.vctr_social_login_button_apple_style) + } + SsoIdentityProvider.ID_FACEBOOK -> { + MaterialButton(context, null, R.attr.vctr_social_login_button_facebook_style) + } + SsoIdentityProvider.ID_TWITTER -> { + MaterialButton(context, null, R.attr.vctr_social_login_button_twitter_style) + } + else -> { + // TODO Use iconUrl + MaterialButton(context, null, R.attr.materialButtonStyle).apply { + transformationMethod = null + textAlignment = View.TEXT_ALIGNMENT_CENTER + } + } + } + button.text = getButtonTitle(identityProvider.name) + button.setTag(R.id.loginSignupSigninSocialLoginButtons, identityProvider.id) + button.setOnClickListener { + listener?.onProviderSelected(identityProvider.id) + } + addView(button) + } + } + + private fun getButtonTitle(providerName: String?): String { + return when (mode) { + Mode.MODE_SIGN_IN -> context.getString(R.string.login_social_signin_with, providerName) + Mode.MODE_SIGN_UP -> context.getString(R.string.login_social_signup_with, providerName) + Mode.MODE_CONTINUE -> context.getString(R.string.login_social_continue_with, providerName) + } + } + + init { + this.orientation = VERTICAL + gravity = Gravity.CENTER + clipToPadding = false + clipChildren = false + if (isInEditMode) { + ssoIdentityProviders = listOf( + SsoIdentityProvider(SsoIdentityProvider.ID_GOOGLE, "Google", null), + SsoIdentityProvider(SsoIdentityProvider.ID_FACEBOOK, "Facebook", null), + SsoIdentityProvider(SsoIdentityProvider.ID_APPLE, "Apple", null), + SsoIdentityProvider(SsoIdentityProvider.ID_GITHUB, "GitHub", null), + SsoIdentityProvider(SsoIdentityProvider.ID_TWITTER, "Twitter", null), + SsoIdentityProvider("Custom_pro", "SSO", null) + ) + } + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.SocialLoginButtonsView, 0, 0) + val modeAttr = typedArray.getInt(R.styleable.SocialLoginButtonsView_signMode, 2) + mode = when (modeAttr) { + 0 -> Mode.MODE_SIGN_IN + 1 -> Mode.MODE_SIGN_UP + else -> Mode.MODE_CONTINUE + } + typedArray.recycle() + update() + } + + fun dpToPx(dp: Int): Int { + val resources = context.resources + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics).toInt() + } +} diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt index 989fc0aadb..89fa4a982a 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt @@ -107,7 +107,7 @@ class SoftLogoutController @Inject constructor( } is Success -> { when (state.asyncHomeServerLoginFlowRequest.invoke()) { - LoginMode.Password -> { + LoginMode.Password -> { loginPasswordFormItem { id("passwordForm") stringProvider(stringProvider) @@ -120,21 +120,23 @@ class SoftLogoutController @Inject constructor( submitClickListener { password -> listener?.signinSubmit(password) } } } - LoginMode.Sso -> { + is LoginMode.Sso -> { loginCenterButtonItem { id("sso") text(stringProvider.getString(R.string.login_signin_sso)) listener { listener?.signinFallbackSubmit() } } } - LoginMode.Unsupported -> { + is LoginMode.SsoAndPassword -> { + } + LoginMode.Unsupported -> { loginCenterButtonItem { id("fallback") text(stringProvider.getString(R.string.login_signin)) listener { listener?.signinFallbackSubmit() } } } - LoginMode.Unknown -> Unit // Should not happen + LoginMode.Unknown -> Unit // Should not happen } } } diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutFragment.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutFragment.kt index dbd5028401..b85c9a8763 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutFragment.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutFragment.kt @@ -54,14 +54,27 @@ class SoftLogoutFragment @Inject constructor( softLogoutViewModel.subscribe(this) { softLogoutViewState -> softLogoutController.update(softLogoutViewState) - - when (softLogoutViewState.asyncHomeServerLoginFlowRequest.invoke()) { - LoginMode.Sso, + when (val mode = softLogoutViewState.asyncHomeServerLoginFlowRequest.invoke()) { + is LoginMode.SsoAndPassword -> { + loginViewModel.handle(LoginAction.SetupSsoForSessionRecovery( + softLogoutViewState.homeServerUrl, + softLogoutViewState.deviceId, + mode.ssoIdentityProviders + )) + } + is LoginMode.Sso -> { + loginViewModel.handle(LoginAction.SetupSsoForSessionRecovery( + softLogoutViewState.homeServerUrl, + softLogoutViewState.deviceId, + mode.ssoIdentityProviders + )) + } LoginMode.Unsupported -> { // Prepare the loginViewModel for a SSO/login fallback recovery loginViewModel.handle(LoginAction.SetupSsoForSessionRecovery( softLogoutViewState.homeServerUrl, - softLogoutViewState.deviceId + softLogoutViewState.deviceId, + null )) } else -> Unit diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt index 4f6110aab1..f1d9a66342 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt @@ -105,9 +105,11 @@ class SoftLogoutViewModel @AssistedInject constructor( is LoginFlowResult.Success -> { val loginMode = when { // SSO login is taken first - data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso - data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password - else -> LoginMode.Unsupported + 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 } setState { diff --git a/vector/src/main/res/color/button_social_google_background_selector_dark.xml b/vector/src/main/res/color/button_social_google_background_selector_dark.xml new file mode 100644 index 0000000000..1369414e58 --- /dev/null +++ b/vector/src/main/res/color/button_social_google_background_selector_dark.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/color/button_social_google_background_selector_light.xml b/vector/src/main/res/color/button_social_google_background_selector_light.xml new file mode 100644 index 0000000000..bcac13885b --- /dev/null +++ b/vector/src/main/res/color/button_social_google_background_selector_light.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/ic_social_apple.xml b/vector/src/main/res/drawable/ic_social_apple.xml new file mode 100644 index 0000000000..5d745a12a3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_social_apple.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/drawable/ic_social_facebook.xml b/vector/src/main/res/drawable/ic_social_facebook.xml new file mode 100644 index 0000000000..77cb8be760 --- /dev/null +++ b/vector/src/main/res/drawable/ic_social_facebook.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/drawable/ic_social_github.xml b/vector/src/main/res/drawable/ic_social_github.xml new file mode 100644 index 0000000000..602f16aa60 --- /dev/null +++ b/vector/src/main/res/drawable/ic_social_github.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/drawable/ic_social_google.xml b/vector/src/main/res/drawable/ic_social_google.xml new file mode 100644 index 0000000000..1518c4cb29 --- /dev/null +++ b/vector/src/main/res/drawable/ic_social_google.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/ic_social_twitter.xml b/vector/src/main/res/drawable/ic_social_twitter.xml new file mode 100644 index 0000000000..7139c3c9bc --- /dev/null +++ b/vector/src/main/res/drawable/ic_social_twitter.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/layout/fragment_login.xml b/vector/src/main/res/layout/fragment_login.xml index ca7d267c80..da41878365 100644 --- a/vector/src/main/res/layout/fragment_login.xml +++ b/vector/src/main/res/layout/fragment_login.xml @@ -19,9 +19,9 @@ android:id="@+id/loginServerIcon" android:layout_width="wrap_content" android:layout_height="wrap_content" - tools:src="@drawable/ic_logo_matrix_org" app:tint="?riotx_text_primary" - tools:ignore="MissingPrefix" /> + tools:ignore="MissingPrefix" + tools:src="@drawable/ic_logo_matrix_org" /> @@ -136,6 +136,35 @@ + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_url_form.xml b/vector/src/main/res/layout/fragment_login_server_url_form.xml index 37122e8e43..a441fee3be 100644 --- a/vector/src/main/res/layout/fragment_login_server_url_form.xml +++ b/vector/src/main/res/layout/fragment_login_server_url_form.xml @@ -53,14 +53,14 @@ - + + + tools:ignore="MissingPrefix,UnknownId" + tools:src="@drawable/ic_logo_matrix_org" + tools:visibility="visible" /> + + + + + + + + + diff --git a/vector/src/main/res/layout/item_completion_homeserver.xml b/vector/src/main/res/layout/item_completion_homeserver.xml new file mode 100644 index 0000000000..ec1cb037eb --- /dev/null +++ b/vector/src/main/res/layout/item_completion_homeserver.xml @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/vector/src/main/res/values/attrs.xml b/vector/src/main/res/values/attrs.xml index 55a4f9a84e..b97a8f3837 100644 --- a/vector/src/main/res/values/attrs.xml +++ b/vector/src/main/res/values/attrs.xml @@ -41,6 +41,12 @@ + + + + + + @@ -66,4 +72,12 @@ + + + + + + + + diff --git a/vector/src/main/res/values/colors_riotx.xml b/vector/src/main/res/values/colors_riotx.xml index 647ada65be..957d30398b 100644 --- a/vector/src/main/res/values/colors_riotx.xml +++ b/vector/src/main/res/values/colors_riotx.xml @@ -41,6 +41,7 @@ #FF000000 #FFFFFFFF #55000000 + #8A000000 Connect to %1$s @@ -1994,6 +2001,7 @@ Sign Up Sign In Continue with SSO + Clear history Element Matrix Services Address Address diff --git a/vector/src/main/res/values/styles_social_login.xml b/vector/src/main/res/values/styles_social_login.xml new file mode 100644 index 0000000000..796965cee1 --- /dev/null +++ b/vector/src/main/res/values/styles_social_login.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/theme_dark.xml b/vector/src/main/res/values/theme_dark.xml index 6ebf8e2b9b..6be0adb907 100644 --- a/vector/src/main/res/values/theme_dark.xml +++ b/vector/src/main/res/values/theme_dark.xml @@ -194,6 +194,12 @@ @transition/image_preview_transition @transition/image_preview_transition + + @style/WidgetButtonSocialLogin.Google.Dark + @style/WidgetButtonSocialLogin.Github.Dark + @style/WidgetButtonSocialLogin.Facebook.Dark + @style/WidgetButtonSocialLogin.Twitter.Dark + @style/WidgetButtonSocialLogin.Apple.Dark