diff --git a/FlowCrypt/build.gradle.kts b/FlowCrypt/build.gradle.kts index 56c40930d1..c33b3dfb36 100644 --- a/FlowCrypt/build.gradle.kts +++ b/FlowCrypt/build.gradle.kts @@ -510,11 +510,15 @@ dependencies { implementation("androidx.navigation:navigation-ui-ktx:2.10.0") implementation("androidx.navigation:navigation-runtime-ktx:2.10.0") implementation("androidx.webkit:webkit:1.16.0") + implementation("androidx.credentials:credentials:1.6.0") + implementation("androidx.credentials:credentials-play-services-auth:1.6.0") implementation("com.google.android.gms:play-services-base:18.10.1") - implementation("com.google.android.gms:play-services-auth:21.6.0") + implementation("com.google.android.gms:play-services-auth:22.0.0") implementation("com.google.android.material:material:1.14.0") implementation("com.google.android.flexbox:flexbox:3.0.0") + implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0") + implementation("com.google.code.gson:gson:2.14.0") implementation("com.google.api-client:google-api-client-android:2.9.1") implementation("com.google.apis:google-api-services-gmail:v1-rev20251201-2.0.0") diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SignInScreenFlowTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SignInScreenFlowTest.kt index 2bc17ee79f..c3a5fbbb67 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SignInScreenFlowTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/SignInScreenFlowTest.kt @@ -8,8 +8,6 @@ package com.flowcrypt.email.ui.activity import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents.intended -import androidx.test.espresso.intent.matcher.IntentMatchers.toPackage import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withParent @@ -25,6 +23,11 @@ import com.flowcrypt.email.rules.GrantPermissionRuleChooser import com.flowcrypt.email.rules.RetryRule import com.flowcrypt.email.rules.ScreenshotTestRule import org.hamcrest.Matchers.allOf +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain @@ -65,7 +68,9 @@ class SignInScreenFlowTest : BaseTest() { .check(matches(isDisplayed())) .perform(click()) //check that the Google Sign-in screen displayed - intended(toPackage("com.google.android.gms")) + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + assertTrue(device.wait(Until.hasObject(By.pkg("com.google.android.gms")), 5000)) + device.pressBack() } @Test diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseSignTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseSignTest.kt index ba48d32f0d..26ef47b62a 100644 --- a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseSignTest.kt +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/base/BaseSignTest.kt @@ -5,27 +5,24 @@ package com.flowcrypt.email.ui.base -import android.app.Activity -import android.app.Instrumentation -import android.content.Intent +import android.net.Uri import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.intent.Intents.intending -import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import com.flowcrypt.email.R import com.flowcrypt.email.base.BaseTest -import com.flowcrypt.email.util.google.GoogleApiClientHelper -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.flowcrypt.email.util.FlavorSettings +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import okhttp3.mockwebserver.RecordedRequest import org.jose4j.jwa.AlgorithmConstraints import org.jose4j.jws.AlgorithmIdentifiers import org.jose4j.jws.JsonWebSignature import org.jose4j.jwt.JwtClaims import org.jose4j.jwt.consumer.JwtConsumerBuilder +import org.json.JSONObject +import org.junit.After /** @@ -33,17 +30,25 @@ import org.jose4j.jwt.consumer.JwtConsumerBuilder */ abstract class BaseSignTest : BaseTest() { - protected fun setupAndClickSignInButton(signInAccountJson: String) { - val intent = Intent() - intent.putExtra("googleSignInAccount", GoogleSignInAccount.zaa(signInAccountJson)) - - val signInIntent = GoogleSignIn.getClient( - getTargetContext(), - GoogleApiClientHelper.generateGoogleSignInOptions() - ).signInIntent + @After + fun resetGoogleIdTokenCredential() { + FlavorSettings.setGoogleIdTokenCredential(null) + } - intending(hasComponent(signInIntent.component)) - .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, intent)) + protected fun setupAndClickSignInButton(signInAccountJson: String) { + val jsonObject = JSONObject(signInAccountJson) + FlavorSettings.setGoogleIdTokenCredential( + GoogleIdTokenCredential( + id = jsonObject.getString("email"), + idToken = jsonObject.getString("tokenId"), + displayName = jsonObject.optString("displayName").ifEmpty { null }, + familyName = jsonObject.optString("familyName").ifEmpty { null }, + givenName = jsonObject.optString("givenName").ifEmpty { null }, + profilePictureUri = jsonObject.optString("photoUrl").takeIf { it.isNotEmpty() } + ?.let(Uri::parse), + phoneNumber = null + ) + ) onView(withId(R.id.buttonSignInWithGmail)) .check(matches(isDisplayed())) diff --git a/FlowCrypt/src/consumerUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt b/FlowCrypt/src/consumerUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt index d54516a4d9..1fd7b3587e 100644 --- a/FlowCrypt/src/consumerUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt +++ b/FlowCrypt/src/consumerUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt @@ -7,12 +7,15 @@ package com.flowcrypt.email.util import android.content.Context import androidx.test.espresso.idling.CountingIdlingResource +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import java.util.UUID /** * @author Denys Bondarenko */ object FlavorSettings : EnvironmentSettings { + private var cachedGoogleIdTokenCredential: GoogleIdTokenCredential? = null + private val countingIdlingResource: CountingIdlingResource = CountingIdlingResource( GeneralUtil.genIdlingResourcesName(this::class.java), GeneralUtil.isDebugBuild() @@ -20,6 +23,10 @@ object FlavorSettings : EnvironmentSettings { override fun configure(context: Context) {} override fun getCountingIdlingResource() = countingIdlingResource override fun getGoogleIdToken(): String = UUID.randomUUID().toString() + override fun getGoogleIdTokenCredential(): GoogleIdTokenCredential? = cachedGoogleIdTokenCredential + fun setGoogleIdTokenCredential(credential: GoogleIdTokenCredential?) { + cachedGoogleIdTokenCredential = credential + } override fun getGmailAPIRootUrl() = "https://flowcrypt.test/" override fun isGMailAPIHttpRequestInitializerEnabled(): Boolean = false } diff --git a/FlowCrypt/src/enterpriseUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt b/FlowCrypt/src/enterpriseUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt index 7d19536241..3f390d9187 100644 --- a/FlowCrypt/src/enterpriseUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt +++ b/FlowCrypt/src/enterpriseUiTests/java/com/flowcrypt/email/util/FlavorSettings.kt @@ -7,12 +7,15 @@ package com.flowcrypt.email.util import android.content.Context import androidx.test.espresso.idling.CountingIdlingResource +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import java.util.UUID /** * @author Denys Bondarenko */ object FlavorSettings : EnvironmentSettings { + private var cachedGoogleIdTokenCredential: GoogleIdTokenCredential? = null + private val countingIdlingResource: CountingIdlingResource = CountingIdlingResource( GeneralUtil.genIdlingResourcesName(this::class.java), GeneralUtil.isDebugBuild() @@ -21,6 +24,10 @@ object FlavorSettings : EnvironmentSettings { override fun configure(context: Context) {} override fun getCountingIdlingResource() = countingIdlingResource override fun getGoogleIdToken(): String = UUID.randomUUID().toString() + override fun getGoogleIdTokenCredential(): GoogleIdTokenCredential? = cachedGoogleIdTokenCredential + fun setGoogleIdTokenCredential(credential: GoogleIdTokenCredential?) { + cachedGoogleIdTokenCredential = credential + } override fun getGmailAPIRootUrl() = "https://flowcrypt.test/" override fun isGMailAPIHttpRequestInitializerEnabled(): Boolean = false } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt index 867727c7a9..c692878f1f 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/FoldersManager.kt @@ -231,6 +231,9 @@ class FoldersManager constructor(val accountEntity: AccountEntity) { */ fun getSortedServerFolders(): Collection { val localFolders = serverFolders.toMutableList() + if (localFolders.size <= 1) { + return localFolders + } val sortedList = arrayOfNulls(localFolders.size) val inbox = folderInbox?.let { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/AccountEntity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/AccountEntity.kt index 26cf5f33a5..41c8127114 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/AccountEntity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/database/entity/AccountEntity.kt @@ -21,7 +21,8 @@ import com.flowcrypt.email.api.email.model.AuthCredentials import com.flowcrypt.email.api.email.model.SecurityType import com.flowcrypt.email.api.retrofit.response.model.ClientConfiguration import com.flowcrypt.email.security.KeyStoreCryptoManager -import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.parcelize.IgnoredOnParcel @@ -103,22 +104,20 @@ data class AccountEntity( get() = JavaEmailConstants.AUTH_MECHANISMS_XOAUTH2 == imapAuthMechanisms constructor( - googleSignInAccount: GoogleSignInAccount, + googleIdTokenCredential: GoogleIdTokenCredential, clientConfiguration: ClientConfiguration? = null, useCustomerFesUrl: Boolean, useStartTlsForSmtp: Boolean = false, ) : this( - email = requireNotNull(googleSignInAccount.email).lowercase(), - accountType = googleSignInAccount.account?.type?.lowercase() ?: EmailUtil.getDomain( - requireNotNull(googleSignInAccount.email) - ).ifEmpty { ACCOUNT_TYPE_UNKNOWN }, - displayName = googleSignInAccount.displayName, - givenName = googleSignInAccount.givenName, - familyName = googleSignInAccount.familyName, - photoUrl = googleSignInAccount.photoUrl?.toString(), + email = googleIdTokenCredential.id.lowercase(), + accountType = GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE, + displayName = googleIdTokenCredential.displayName, + givenName = googleIdTokenCredential.givenName, + familyName = googleIdTokenCredential.familyName, + photoUrl = googleIdTokenCredential.profilePictureUri?.toString(), isEnabled = true, isActive = false, - username = requireNotNull(googleSignInAccount.email), + username = googleIdTokenCredential.id, password = "", imapServer = GmailConstants.GMAIL_IMAP_SERVER, imapPort = GmailConstants.GMAIL_IMAP_PORT, diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/lang/ExceptionExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/lang/ExceptionExt.kt index a2460479f2..38988ade0a 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/lang/ExceptionExt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/lang/ExceptionExt.kt @@ -5,6 +5,9 @@ package com.flowcrypt.email.extensions.java.lang +import androidx.fragment.app.Fragment +import com.flowcrypt.email.R +import com.flowcrypt.email.extensions.androidx.fragment.app.showInfoDialog import com.flowcrypt.email.util.GeneralUtil /** @@ -15,3 +18,13 @@ fun Exception.printStackTraceIfDebugOnly() { printStackTrace() } } + +fun Exception.showDialogWithErrorDetails(fragment: Fragment) { + fragment.showInfoDialog( + dialogTitle = "", + dialogMsg = fragment.getString( + R.string.error_occurred_with_details_please_try_again, + localizedMessage + ) + ) +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/MainActivity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/MainActivity.kt index 7777c0d540..72d6ebbb69 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/MainActivity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/MainActivity.kt @@ -33,6 +33,8 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.GravityCompat import androidx.core.view.get import androidx.core.view.size +import androidx.credentials.ClearCredentialStateRequest +import androidx.credentials.CredentialManager import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver @@ -65,6 +67,7 @@ import com.flowcrypt.email.extensions.android.content.getParcelableExtraViaExt import com.flowcrypt.email.extensions.decrementSafely import com.flowcrypt.email.extensions.exceptionMsg import com.flowcrypt.email.extensions.incrementSafely +import com.flowcrypt.email.extensions.java.lang.printStackTraceIfDebugOnly import com.flowcrypt.email.extensions.kotlin.parseAsColorBasedOnDefaultSettings import com.flowcrypt.email.extensions.showFeedbackFragment import com.flowcrypt.email.extensions.showInfoDialog @@ -87,16 +90,12 @@ import com.flowcrypt.email.util.GeneralUtil import com.flowcrypt.email.util.SharedPreferencesHelper import com.flowcrypt.email.util.exception.CommonConnectionException import com.flowcrypt.email.util.exception.EmptyPassphraseException -import com.flowcrypt.email.util.google.GoogleApiClientHelper -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInClient import kotlinx.coroutines.launch /** * @author Denys Bondarenko */ class MainActivity : BaseActivity() { - private lateinit var client: GoogleSignInClient private var navigationViewManager: NavigationViewManager? = null private val launcherViewModel: LauncherViewModel by viewModels() @@ -161,8 +160,6 @@ class MainActivity : BaseActivity() { super.onCreate(savedInstanceState) observeMovingToBackground() - client = GoogleSignIn.getClient(this, GoogleApiClientHelper.generateGoogleSignInOptions()) - IdleService.start(this) IdleService.bind(this, idleServiceConnection) @@ -434,7 +431,12 @@ class MainActivity : BaseActivity() { private fun logout() { lifecycleScope.launch { activeAccount?.let { accountEntity -> - if (accountEntity.accountType == AccountEntity.ACCOUNT_TYPE_GOOGLE) client.signOut() + if (accountEntity.accountType == AccountEntity.ACCOUNT_TYPE_GOOGLE) { + runCatching { + CredentialManager.create(this@MainActivity) + .clearCredentialState(ClearCredentialStateRequest()) + } + } FlavorSettings.getCountingIdlingResource().incrementSafely(this@MainActivity) WorkManager.getInstance(applicationContext).cancelAllWorkByTag(BaseSyncWorker.TAG_SYNC) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MainSignInFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MainSignInFragment.kt index 9cd10083cf..c4d2319ed0 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MainSignInFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/MainSignInFragment.kt @@ -5,18 +5,27 @@ package com.flowcrypt.email.ui.activity.fragment +import android.accounts.Account import android.app.Activity import android.content.Context import android.content.Intent +import android.content.MutableContextWrapper import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.ActivityResult +import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDirections import com.flowcrypt.email.BuildConfig import com.flowcrypt.email.Constants @@ -55,6 +64,7 @@ import com.flowcrypt.email.ui.activity.fragment.CheckKeysFragment.CheckingState. import com.flowcrypt.email.ui.activity.fragment.CheckKeysFragment.CheckingState.Companion.SKIP_REMAINING_KEYS import com.flowcrypt.email.ui.activity.fragment.base.BaseSingInFragment import com.flowcrypt.email.ui.activity.fragment.dialog.TwoWayDialogFragment +import com.flowcrypt.email.util.FlavorSettings import com.flowcrypt.email.util.GeneralUtil import com.flowcrypt.email.util.exception.AccountAlreadyAddedException import com.flowcrypt.email.util.exception.CommonConnectionException @@ -62,14 +72,15 @@ import com.flowcrypt.email.util.exception.EkmNotSupportedException import com.flowcrypt.email.util.exception.ExceptionUtil import com.flowcrypt.email.util.exception.UnsupportedClientConfigurationException import com.flowcrypt.email.util.google.GoogleApiClientHelper -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.auth.api.signin.GoogleSignInClient -import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes +import com.google.android.gms.auth.api.identity.AuthorizationClient +import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.common.api.ApiException -import com.google.android.gms.tasks.Task +import com.google.android.gms.common.api.CommonStatusCodes import com.google.android.material.snackbar.Snackbar +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException +import kotlinx.coroutines.launch import org.eclipse.angus.mail.util.MailConnectException import org.pgpainless.util.Passphrase import java.net.HttpURLConnection @@ -83,8 +94,9 @@ class MainSignInFragment : BaseSingInFragment() { override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentMainSignInBinding.inflate(inflater, container, false) - private lateinit var client: GoogleSignInClient - private var cachedGoogleSignInAccount: GoogleSignInAccount? = null + private lateinit var credentialManager: CredentialManager + private lateinit var authorizationClient: AuthorizationClient + private var cachedGoogleIdTokenCredential: GoogleIdTokenCredential? = null private var cachedClientConfiguration: ClientConfiguration? = null private var cachedBaseFesUrlPath: String? = null @@ -93,10 +105,20 @@ class MainSignInFragment : BaseSingInFragment() { private val ekmViewModel: EkmViewModel by viewModels() private var useStartTlsForSmtp = false - private val forActivityResultSignIn = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() + private val forActivityResultAuthorization = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() ) { result: ActivityResult -> - handleSignInResult(result.resultCode, GoogleSignIn.getSignedInAccountFromIntent(result.data)) + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + try { + authorizationClient.getAuthorizationResultFromIntent(result.data!!) + continueAfterGoogleAuthorization() + } catch (e: ApiException) { + handleGoogleApiException(e) + } + } else { + cachedGoogleIdTokenCredential = null + showContent() + } } private val forActivityResultSignInError = registerForActivityResult( @@ -118,7 +140,8 @@ class MainSignInFragment : BaseSingInFragment() { override fun onAttach(context: Context) { super.onAttach(context) - client = GoogleSignIn.getClient(context, GoogleApiClientHelper.generateGoogleSignInOptions()) + credentialManager = CredentialManager.create(context) + authorizationClient = Identity.getAuthorizationClient(context) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -139,9 +162,9 @@ class MainSignInFragment : BaseSingInFragment() { override fun getTempAccount(): AccountEntity? { val sharedTenantFesBaseUrlPath = GeneralUtil.genBaseFesUrlPath(useCustomerFesUrl = false) - return cachedGoogleSignInAccount?.let { + return cachedGoogleIdTokenCredential?.let { AccountEntity( - googleSignInAccount = it, + googleIdTokenCredential = it, clientConfiguration = cachedClientConfiguration, useCustomerFesUrl = cachedBaseFesUrlPath?.isNotEmpty() == true && cachedBaseFesUrlPath != sharedTenantFesBaseUrlPath, @@ -212,80 +235,119 @@ class MainSignInFragment : BaseSingInFragment() { } private fun signInWithGmail() { - cachedGoogleSignInAccount = null - client.signOut() - forActivityResultSignIn.launch(client.signInIntent) - } - - private fun handleSignInResult(resultCode: Int, task: Task) { - try { - if (task.isSuccessful) { - cachedGoogleSignInAccount = task.getResult(ApiException::class.java) - - val account = cachedGoogleSignInAccount?.account?.name ?: return - cachedBaseFesUrlPath = GeneralUtil.genBaseFesUrlPath(useCustomerFesUrl = false) - - val publicEmailDomains = EmailUtil.getPublicEmailDomains() - val domain = EmailUtil.getDomain(account) - if (domain in publicEmailDomains) { - if (BuildConfig.FLAVOR == Constants.FLAVOR_NAME_ENTERPRISE) { - cachedGoogleSignInAccount = null - showInfoDialog( - dialogTitle = "", - dialogMsg = getString( - R.string.enterprise_does_not_support_pub_domains, - getString(R.string.app_name), - domain - ), - isCancelable = true - ) - } else { - val idToken = cachedGoogleSignInAccount?.idToken - if (idToken == null) { - showInfoDialog( - dialogTitle = "", - dialogMsg = getString( - R.string.error_occurred_with_details_please_try_again, - "GoogleSignInAccount.idToken == null" - ), - isCancelable = true - ) - } else { - clientConfigurationViewModel.fetchClientConfiguration( - idToken = idToken, - baseFesUrlPath = GeneralUtil.genBaseFesUrlPath(useCustomerFesUrl = false), - domain = domain - ) - } - } + cachedGoogleIdTokenCredential = null + FlavorSettings.getGoogleIdTokenCredential()?.let { + cachedGoogleIdTokenCredential = it + continueAfterGoogleAuthorization() + return + } + + viewLifecycleOwner.lifecycleScope.launch { + try { + val response = credentialManager.getCredential( + context = MutableContextWrapper(requireActivity()), + request = GoogleApiClientHelper.generateGoogleSignInRequest() + ) + val credential = response.credential + if (credential is CustomCredential && + credential.type in setOf( + GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL, + GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_SIWG_CREDENTIAL + ) + ) { + cachedGoogleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + authorizeGoogleAccount() } else { - checkCustomerUrlFesServerViewModel.checkServerAvailability(account) + showContent() + showInfoSnackbar(msgText = getString(R.string.unsupported_credentials)) } - } else { - val error = task.exception + } catch (_: GetCredentialCancellationException) { + showContent() + } catch (e: NoCredentialException) { + showContent() + showInfoSnackbar(msgText = e.errorMessage?.toString() ?: e.type) + } catch (e: GoogleIdTokenParsingException) { + showContent() + showInfoSnackbar(msgText = e.message ?: getString(R.string.unknown_error)) + } catch (e: GetCredentialException) { + showContent() + showInfoSnackbar(msgText = e.errorMessage?.toString() ?: e.type) + } + } + } - if (error is ApiException) { - throw error + private fun authorizeGoogleAccount() { + val credential = cachedGoogleIdTokenCredential ?: return + val account = Account(credential.id, AccountEntity.ACCOUNT_TYPE_GOOGLE) + authorizationClient.authorize( + GoogleApiClientHelper.generateGoogleAuthorizationRequest(account) + ).addOnSuccessListener { result -> + if (result.hasResolution()) { + val pendingIntent = result.pendingIntent + if (pendingIntent == null) { + showContent() + showInfoSnackbar(msgText = getString(R.string.unknown_error)) + return@addOnSuccessListener } - + forActivityResultAuthorization.launch( + IntentSenderRequest.Builder(pendingIntent.intentSender).build() + ) + } else { + continueAfterGoogleAuthorization() + } + }.addOnFailureListener { exception -> + showContent() + if (exception is ApiException) { + handleGoogleApiException(exception) + } else { showInfoSnackbar( - msgText = error?.message ?: error?.javaClass?.simpleName - ?: getString(R.string.unknown_error) + msgText = exception.message ?: exception.javaClass.simpleName ) } - } catch (e: ApiException) { - val msg = GoogleSignInStatusCodes.getStatusCodeString(e.statusCode) - if (resultCode == Activity.RESULT_OK) { - showInfoSnackbar(msgText = msg) + } + } + + private fun handleGoogleApiException(exception: ApiException) { + showContent() + showInfoSnackbar( + msgText = CommonStatusCodes.getStatusCodeString(exception.statusCode) + ) + } + + private fun continueAfterGoogleAuthorization() { + val credential = cachedGoogleIdTokenCredential ?: return + val account = credential.id + cachedBaseFesUrlPath = GeneralUtil.genBaseFesUrlPath(useCustomerFesUrl = false) + + val publicEmailDomains = EmailUtil.getPublicEmailDomains() + val domain = EmailUtil.getDomain(account) + if (domain in publicEmailDomains) { + if (BuildConfig.FLAVOR == Constants.FLAVOR_NAME_ENTERPRISE) { + cachedGoogleIdTokenCredential = null + showInfoDialog( + dialogTitle = "", + dialogMsg = getString( + R.string.enterprise_does_not_support_pub_domains, + getString(R.string.app_name), + domain + ), + isCancelable = true + ) } else { - toast(msg) + clientConfigurationViewModel.fetchClientConfiguration( + idToken = credential.idToken, + baseFesUrlPath = GeneralUtil.genBaseFesUrlPath(useCustomerFesUrl = false), + domain = domain + ) } + } else { + checkCustomerUrlFesServerViewModel.checkServerAvailability(account) } } - private fun onSignSuccess(googleSignInAccount: GoogleSignInAccount?) { + private fun onSignSuccess(googleIdTokenCredential: GoogleIdTokenCredential?) { val existedAccount = existingAccounts.firstOrNull { - it.email.equals(googleSignInAccount?.email, ignoreCase = true) + it.email.equals(googleIdTokenCredential?.id, ignoreCase = true) } if (existedAccount == null) { @@ -375,7 +437,7 @@ class MainSignInFragment : BaseSingInFragment() { if (original is MailConnectException && !useStartTlsForSmtp) { useStartTlsForSmtp = true - onSignSuccess(cachedGoogleSignInAccount) + onSignSuccess(cachedGoogleIdTokenCredential) return } @@ -439,15 +501,16 @@ class MainSignInFragment : BaseSingInFragment() { when (requestCode) { REQUEST_CODE_RETRY_CHECK_FES_AVAILABILITY -> if (result == TwoWayDialogFragment.RESULT_OK) { - val account = cachedGoogleSignInAccount?.account?.name + val account = cachedGoogleIdTokenCredential?.id ?: return@setFragmentResultListenerForTwoWayDialog checkCustomerUrlFesServerViewModel.checkServerAvailability(account) } REQUEST_CODE_RETRY_GET_CLIENT_CONFIGURATION -> if (result == TwoWayDialogFragment.RESULT_OK) { val idToken = - cachedGoogleSignInAccount?.idToken ?: return@setFragmentResultListenerForTwoWayDialog - val account = cachedGoogleSignInAccount?.account?.name + cachedGoogleIdTokenCredential?.idToken + ?: return@setFragmentResultListenerForTwoWayDialog + val account = cachedGoogleIdTokenCredential?.id ?: return@setFragmentResultListenerForTwoWayDialog val domain = EmailUtil.getDomain(account) val baseFesUrlPath = @@ -461,7 +524,8 @@ class MainSignInFragment : BaseSingInFragment() { REQUEST_CODE_RETRY_FETCH_PRV_KEYS_VIA_EKM -> if (result == TwoWayDialogFragment.RESULT_OK) { val idToken = - cachedGoogleSignInAccount?.idToken ?: return@setFragmentResultListenerForTwoWayDialog + cachedGoogleIdTokenCredential?.idToken + ?: return@setFragmentResultListenerForTwoWayDialog cachedClientConfiguration?.let { ekmViewModel.fetchPrvKeys(it, idToken) } } } @@ -496,13 +560,13 @@ class MainSignInFragment : BaseSingInFragment() { privateKeysViewModel.doAdditionalActionsAfterPrivateKeyCreation( accountEntity = account, keys = keys, - idToken = cachedGoogleSignInAccount?.idToken + idToken = cachedGoogleIdTokenCredential?.idToken ) } } CreateOrImportPrivateKeyDuringSetupFragment.Result.USE_ANOTHER_ACCOUNT -> { - this.cachedGoogleSignInAccount = null + this.cachedGoogleIdTokenCredential = null showContent() } } @@ -568,9 +632,9 @@ class MainSignInFragment : BaseSingInFragment() { Result.Status.SUCCESS -> { if (it.data?.service in ApiClientRepository.FES.ALLOWED_SERVICES) { - cachedGoogleSignInAccount?.account?.name?.let { account -> + cachedGoogleIdTokenCredential?.id?.let { account -> val domain = EmailUtil.getDomain(account) - val idToken = cachedGoogleSignInAccount?.idToken ?: return@let + val idToken = cachedGoogleIdTokenCredential?.idToken ?: return@let val baseFesUrlPath = GeneralUtil.genBaseFesUrlPath( useCustomerFesUrl = true, domain = domain @@ -668,8 +732,8 @@ class MainSignInFragment : BaseSingInFragment() { } private fun continueWithRegularFlow() { - val idToken = cachedGoogleSignInAccount?.idToken - val account = cachedGoogleSignInAccount?.account?.name + val idToken = cachedGoogleIdTokenCredential?.idToken + val account = cachedGoogleIdTokenCredential?.id val baseFesUrlPath = cachedBaseFesUrlPath if (idToken != null && account != null && baseFesUrlPath != null) { @@ -694,7 +758,7 @@ class MainSignInFragment : BaseSingInFragment() { } Result.Status.SUCCESS -> { - val idToken = cachedGoogleSignInAccount?.idToken + val idToken = cachedGoogleIdTokenCredential?.idToken cachedClientConfiguration = it.data?.clientConfiguration if (idToken != null) { @@ -752,7 +816,7 @@ class MainSignInFragment : BaseSingInFragment() { showContent() when (it.exception) { is EkmNotSupportedException -> { - onSignSuccess(cachedGoogleSignInAccount) + onSignSuccess(cachedGoogleIdTokenCredential) } is UnsupportedClientConfigurationException -> { @@ -852,7 +916,7 @@ class MainSignInFragment : BaseSingInFragment() { if (accountEntity == null) { showContent() - ExceptionUtil.handleError(NullPointerException("GoogleSignInAccount is null!")) + ExceptionUtil.handleError(NullPointerException("GoogleIdTokenCredential is null!")) toast(R.string.error_occurred_try_again_later) } else { accountViewModel.addNewAccount(accountEntity) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/EnvironmentSettings.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/EnvironmentSettings.kt index 7ae47eaf8f..01f0dbf38a 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/util/EnvironmentSettings.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/EnvironmentSettings.kt @@ -7,6 +7,7 @@ package com.flowcrypt.email.util import android.content.Context import androidx.test.espresso.idling.CountingIdlingResource +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import java.util.Properties /** @@ -18,6 +19,7 @@ interface EnvironmentSettings { fun configure(context: Context) fun getCountingIdlingResource(): CountingIdlingResource? = null fun getGoogleIdToken(): String? = null + fun getGoogleIdTokenCredential(): GoogleIdTokenCredential? = null fun getGmailAPIRootUrl(): String? = null fun isGMailAPIHttpRequestInitializerEnabled(): Boolean = true } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt index a5a96307fe..0051b6f502 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/GeneralUtil.kt @@ -45,7 +45,7 @@ import com.flowcrypt.email.ui.notifications.ErrorNotificationManager import com.flowcrypt.email.util.exception.CommonConnectionException import com.flowcrypt.email.util.exception.ExceptionUtil import com.flowcrypt.email.util.google.GoogleApiClientHelper -import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.GoogleAuthUtil import jakarta.mail.Message import jakarta.mail.MessagingException import jakarta.mail.internet.InternetAddress @@ -496,19 +496,19 @@ class GeneralUtil { accountEntity: AccountEntity ): String = withContext(Dispatchers.IO) { - //before fetch idToken from [GoogleSignInClient] - //we try to get IdToken from the flavor settings + // Before fetching an ID token, try the flavor settings used by UI tests. @Suppress("UNNECESSARY_SAFE_CALL", "KotlinRedundantDiagnosticSuppress") FlavorSettings.getGoogleIdToken()?.let { return@withContext it } - val googleSignInClient = GoogleSignIn.getClient( - context, - GoogleApiClientHelper.generateGoogleSignInOptions(accountEntity.account) - ) - val silentSignIn = googleSignInClient.silentSignIn() - if (!silentSignIn.isSuccessful || silentSignIn.result.isExpired) { + val idToken = try { + GoogleAuthUtil.getToken( + context, + accountEntity.account, + GoogleApiClientHelper.ID_TOKEN_SCOPE + ) + } catch (e: Exception) { if (retryAttempt <= maxRetryAttemptCount) { - //do delay for 10 seconds and try again. Max attempts == maxRetryAttemptCount + // Delay for 10 seconds and try again. Max attempts == maxRetryAttemptCount. delay(TimeUnit.SECONDS.toMillis(10)) return@withContext getGoogleIdTokenSilently( context, @@ -516,7 +516,9 @@ class GeneralUtil { retryAttempt + 1, accountEntity ) - } else throw IllegalStateException("Could not receive idToken") + } else { + throw IllegalStateException("Could not receive idToken", e) + } } val claims = JwtConsumerBuilder() @@ -526,7 +528,7 @@ class GeneralUtil { .setRelaxVerificationKeyValidation() .setSkipSignatureVerification() .build() - .processToClaims(silentSignIn.result.idToken) + .processToClaims(idToken) val email = claims.getClaimValueAsString("email") @@ -534,7 +536,7 @@ class GeneralUtil { throw IllegalStateException("Received tokenId for a wrong account($email)") } - return@withContext requireNotNull(silentSignIn.result.idToken) + return@withContext idToken } suspend fun preProcessException( diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/acra/CustomReportSender.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/acra/CustomReportSender.kt index b0ced5ac39..cd4e817b2b 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/util/acra/CustomReportSender.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/acra/CustomReportSender.kt @@ -10,7 +10,7 @@ import android.net.Uri import com.flowcrypt.email.api.retrofit.request.model.CrashReportModel import com.flowcrypt.email.database.FlowCryptRoomDatabase import com.flowcrypt.email.util.google.GoogleApiClientHelper -import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.GoogleAuthUtil import com.google.gson.GsonBuilder import org.acra.ReportField import org.acra.config.CoreConfiguration @@ -89,16 +89,15 @@ class CustomReportSender(config: CoreConfiguration) : HttpSender(config, null, n ) { //add Authorization val finalHeaders = (headers ?: emptyMap()).toMutableMap().apply { - val googleSignInClient = GoogleSignIn.getClient( + val activeAccount = FlowCryptRoomDatabase.getDatabase(context) + .accountDao() + .getActiveAccount() + ?: throw IllegalStateException("Active account is missing") + val idToken = GoogleAuthUtil.getToken( context, - GoogleApiClientHelper.generateGoogleSignInOptions() + activeAccount.account, + GoogleApiClientHelper.ID_TOKEN_SCOPE ) - val silentSignIn = googleSignInClient.silentSignIn() - if (!silentSignIn.isSuccessful || silentSignIn.result.isExpired) { - throw IllegalStateException("Could not receive idToken") - } - - val idToken = silentSignIn.result.idToken put("Authorization", "Bearer $idToken") } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/google/GoogleApiClientHelper.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/google/GoogleApiClientHelper.kt index dd10386c7a..d7f909b160 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/util/google/GoogleApiClientHelper.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/google/GoogleApiClientHelper.kt @@ -6,9 +6,11 @@ package com.flowcrypt.email.util.google import android.accounts.Account +import androidx.credentials.GetCredentialRequest import com.flowcrypt.email.Constants -import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption /** * This class describes methods which can be used to work with Google API. @@ -21,15 +23,16 @@ class GoogleApiClientHelper { const val SERVER_CLIENT_ID = "374364070962-n83b6asllhfkhij6slijr61576lqqi3v.apps.googleusercontent.com" - fun generateGoogleSignInOptions(account: Account? = null): GoogleSignInOptions { - val builder = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + const val ID_TOKEN_SCOPE = "audience:server:client_id:$SERVER_CLIENT_ID" - builder.requestScopes(Scope(Constants.SCOPE_MAIL_GOOGLE_COM)) - builder.requestEmail() - builder.requestIdToken(SERVER_CLIENT_ID) - account?.name?.let { builder.setAccountName(it) } + fun generateGoogleSignInRequest(): GetCredentialRequest = GetCredentialRequest( + listOf(GetSignInWithGoogleOption(SERVER_CLIENT_ID)) + ) - return builder.build() - } + fun generateGoogleAuthorizationRequest(account: Account): AuthorizationRequest = + AuthorizationRequest.builder() + .setAccount(account) + .setRequestedScopes(listOf(Scope(Constants.SCOPE_MAIL_GOOGLE_COM))) + .build() } } diff --git a/FlowCrypt/src/main/res/values-ru/strings.xml b/FlowCrypt/src/main/res/values-ru/strings.xml index 5f3cbfb3b9..154c9272d9 100644 --- a/FlowCrypt/src/main/res/values-ru/strings.xml +++ b/FlowCrypt/src/main/res/values-ru/strings.xml @@ -627,6 +627,7 @@ Этот исполняемый файл не был проверен на наличие вирусов, и его загрузка или запуск могут быть опасны.\n\nВсе равно продолжить? Исходящие: отправка… Сообщение не найдено или ярлыки изменены + Неподдерживаемые учетные данные Подпись Что такое подпись электронного письма?\n\nПодпись электронного письма - это текст, где могут быть указаны ваши профессиональные контактные данные, фирменный знак компании или любимая цитата. Он будет автоматически добавлен в конец исходящих сообщений в виде нижнего колонтитула на экране создания сообщения. Пожалуйста, нажмите, чтобы изменить diff --git a/FlowCrypt/src/main/res/values-uk/strings.xml b/FlowCrypt/src/main/res/values-uk/strings.xml index 753583d72a..50b6ccc8f9 100644 --- a/FlowCrypt/src/main/res/values-uk/strings.xml +++ b/FlowCrypt/src/main/res/values-uk/strings.xml @@ -628,6 +628,7 @@ Цей виконуваний файл не було перевірено на наявність вірусів, він може бути небезпечним для завантаження чи запуску.\n\nБажаєте продовжити? Вихідні: надсилання… Повідомлення не знайдено або мітки змінено + Непідтримувані облікові дані Підпис Що таке підпис електронної пошти?\n\nПідпис електронної пошти - це текст, де можуть бути вказані ваші професійні контактні дані та бренд компанії чи улюблена цитата. Його буде автоматично додано в кінці ваших вихідних повідомлень як нижній колонтитул на екрані створення повідомлення. Будь ласка, натисніть, щоб редагувати diff --git a/FlowCrypt/src/main/res/values/strings.xml b/FlowCrypt/src/main/res/values/strings.xml index 5a0ff93be6..241d988962 100644 --- a/FlowCrypt/src/main/res/values/strings.xml +++ b/FlowCrypt/src/main/res/values/strings.xml @@ -642,6 +642,7 @@ This executable file was not checked for viruses, and may be dangerous to download or run.\n\nProceed anyway? Outbox: sending… Message not found or labels changed + Unsupported credentials Signature What is an email signature?\n\nAn email signature is text, like your professional contact details and company branding or a favorite quote. It will be automatically added at the end of your outgoing messages as a footer on the compose message screen. Please tap to edit