Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cb3ec1b
wip
DenBond7 Jun 5, 2024
8f1d476
wip
DenBond7 Jun 5, 2024
cc31ff3
wip
DenBond7 Jun 5, 2024
0d15070
wip
DenBond7 Jun 6, 2024
4a49d9e
wip
DenBond7 Jun 6, 2024
ddab634
wip
DenBond7 Jun 6, 2024
a877fb5
wip
DenBond7 Jun 6, 2024
fe99928
wip
DenBond7 Jun 6, 2024
9f43e56
wip
DenBond7 Jun 6, 2024
2c0ce0f
wip
DenBond7 Jun 6, 2024
a1bc62f
wip
DenBond7 Jun 6, 2024
3238b87
wip
DenBond7 Jun 6, 2024
404fb79
wip
DenBond7 Jun 6, 2024
3e9a35d
wip
DenBond7 Jun 10, 2024
bfd8810
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Jun 10, 2024
4b9d5d4
wip
DenBond7 Jun 11, 2024
de43638
wip
DenBond7 Jun 11, 2024
dce8289
Merge branch 'issue_2531_Migrate_to_Credential_Manager' of github.com…
DenBond7 Jun 11, 2024
6cad720
wip
DenBond7 Jun 11, 2024
bf450af
wip
DenBond7 Jun 11, 2024
31ba3e6
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Jun 12, 2024
49fce88
Disabled some tests.| #2531
DenBond7 Jun 12, 2024
b0a9d51
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Jun 12, 2024
6ed590c
wip
DenBond7 Jun 13, 2024
3410655
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Sep 10, 2026
4f24e7a
Migrated to Credential Manager and play-services-auth 22.0.0. A draft…
DenBond7 Sep 10, 2026
1e68e9a
Fix and re-enable UI tests after Credential Manager migration | #2531
DenBond7 Sep 10, 2026
a578c6a
Remove unused SignInWithGoogleViewModel and revert related Result cha…
DenBond7 Sep 10, 2026
cfa4142
Fix CredentialManagerMisuse lint error by handling NoCredentialException
DenBond7 Sep 10, 2026
2a27946
Handle invalid Google ID token cache eviction and refactor retry | #2531
DenBond7 Sep 18, 2026
a815569
Removed some code
DenBond7 Sep 18, 2026
0623d1d
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Sep 18, 2026
326e503
Handle ClearCredentialException during Google account logout | #2531
DenBond7 Sep 18, 2026
44c7c5e
Recover Google sign-in after process recreation | #2531
DenBond7 Sep 18, 2026
a34a684
Use token-derived email for Google sign-in | #2531
DenBond7 Sep 18, 2026
3519d50
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Sep 21, 2026
c4ef59b
Merge branch 'master' into issue_2531_Migrate_to_Credential_Manager
DenBond7 Sep 22, 2026
eb09a24
Require Gmail access scope during Google authorization | #2531
DenBond7 Sep 22, 2026
f7f2a77
Add instrumentation coverage for denied Gmail access and sign-in retr…
DenBond7 Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion FlowCrypt/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -510,11 +510,15 @@ dependencies {
implementation("androidx.navigation:navigation-ui-ktx:2.10.1")
implementation("androidx.navigation:navigation-runtime-ktx:2.10.1")
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.11.0")
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.activityScenarioRule
Expand Down Expand Up @@ -50,13 +51,16 @@ import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.RecordedRequest
import org.hamcrest.Matchers.not
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.rules.TestName
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import java.net.HttpURLConnection
import java.util.concurrent.atomic.AtomicInteger

/**
* @author Denys Bondarenko
Expand All @@ -72,9 +76,11 @@ class MainSignInFragmentFlowTest : BaseSignTest() {
)

private val testNameRule = TestName()
private val setupRequestCount = AtomicInteger()
private val mockWebServerRule =
FlowCryptMockWebServerRule(TestConstants.MOCK_WEB_SERVER_PORT, object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
setupRequestCount.incrementAndGet()
val gson = ApiHelper.getInstance(getTargetContext()).gson

when {
Expand Down Expand Up @@ -157,6 +163,48 @@ class MainSignInFragmentFlowTest : BaseSignTest() {
.around(activityScenarioRule)
.around(ScreenshotTestRule())

@Test
fun testSignInStopsWhenGmailScopeIsMissing() {
checkSignInStopsWithoutGmailAccess(listOf("openid", "email", "profile"))
}

@Test
fun testSignInStopsWhenNoScopesAreGranted() {
checkSignInStopsWithoutGmailAccess(emptyList())
}

@Test
fun testSignInCanBeRetriedAfterGmailAccessIsDenied() {
checkSignInStopsWithoutGmailAccess(emptyList())

setupAndClickSignInButton(genMockGoogleSignInAccountJson(EMAIL_GMAIL))

// The existing Gmail fixture returns this error during the backup search,
// proving that granting access lets setup proceed past authorization.
checkIsSnackBarDisplayed(EMAIL_GMAIL)
assertTrue(
"Setup should contact the server after Gmail access is granted",
setupRequestCount.get() > 0
)
}

private fun checkSignInStopsWithoutGmailAccess(grantedScopes: List<String>) {
setupAndClickSignInButton(
genMockGoogleSignInAccountJson(EMAIL_GMAIL),
grantedScopes = grantedScopes
)

checkIsSnackBarDisplayed(getResString(R.string.access_was_not_granted))
onView(withId(R.id.buttonSignInWithGmail))
.check(matches(isDisplayed()))
.check(matches(isEnabled()))
assertTrue(
"No account should be saved without Gmail access",
roomDatabase.accountDao().getAccounts().isEmpty()
)
assertEquals("Setup must stop before contacting FES, EKM or Gmail", 0, setupRequestCount.get())
}

@Test
fun testClientConfigurationCombinationNotSupportedForMustAutogenPassPhraseQuietlyExisted() {
setupAndClickSignInButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,61 @@

package com.flowcrypt.email.ui.base

import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import android.net.Uri
import android.os.Bundle
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.Constants
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.gms.auth.api.identity.AuthorizationResult
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


/**
* @author Denys Bondarenko
*/
abstract class BaseSignTest : BaseTest() {

protected fun setupAndClickSignInButton(signInAccountJson: String) {
val intent = Intent()
intent.putExtra("googleSignInAccount", GoogleSignInAccount.zaa(signInAccountJson))
@After
fun resetGoogleSignInMocks() {
FlavorSettings.setGoogleIdTokenCredential(null)
FlavorSettings.setGoogleAuthorizationResult(null)
}

val signInIntent = GoogleSignIn.getClient(
getTargetContext(),
GoogleApiClientHelper.generateGoogleSignInOptions()
).signInIntent
protected fun setupAndClickSignInButton(
signInAccountJson: String,
grantedScopes: List<String> = listOf(Constants.SCOPE_MAIL_GOOGLE_COM)
) {
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
)
)

intending(hasComponent(signInIntent.component))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, intent))
FlavorSettings.setGoogleAuthorizationResult(
AuthorizationResult(null, null, null, grantedScopes, null, null, Bundle.EMPTY)
)

onView(withId(R.id.buttonSignInWithGmail))
.check(matches(isDisplayed()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,32 @@ package com.flowcrypt.email.util

import android.content.Context
import androidx.test.espresso.idling.CountingIdlingResource
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import java.util.UUID

/**
* @author Denys Bondarenko
*/
object FlavorSettings : EnvironmentSettings {
private var cachedGoogleIdTokenCredential: GoogleIdTokenCredential? = null
private var cachedGoogleAuthorizationResult: AuthorizationResult? = null

private val countingIdlingResource: CountingIdlingResource = CountingIdlingResource(
GeneralUtil.genIdlingResourcesName(this::class.java),
GeneralUtil.isDebugBuild()
)
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 getGoogleAuthorizationResult(): AuthorizationResult? = cachedGoogleAuthorizationResult
fun setGoogleAuthorizationResult(result: AuthorizationResult?) {
cachedGoogleAuthorizationResult = result
}
override fun getGmailAPIRootUrl() = "https://flowcrypt.test/"
override fun isGMailAPIHttpRequestInitializerEnabled(): Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ package com.flowcrypt.email.util

import android.content.Context
import androidx.test.espresso.idling.CountingIdlingResource
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import java.util.UUID

/**
* @author Denys Bondarenko
*/
object FlavorSettings : EnvironmentSettings {
private var cachedGoogleIdTokenCredential: GoogleIdTokenCredential? = null
private var cachedGoogleAuthorizationResult: AuthorizationResult? = null

private val countingIdlingResource: CountingIdlingResource = CountingIdlingResource(
GeneralUtil.genIdlingResourcesName(this::class.java),
GeneralUtil.isDebugBuild()
Expand All @@ -21,6 +26,14 @@ 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 getGoogleAuthorizationResult(): AuthorizationResult? = cachedGoogleAuthorizationResult
fun setGoogleAuthorizationResult(result: AuthorizationResult?) {
cachedGoogleAuthorizationResult = result
}
override fun getGmailAPIRootUrl() = "https://flowcrypt.test/"
override fun isGMailAPIHttpRequestInitializerEnabled(): Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = requireGoogleAccountEmail(googleIdTokenCredential).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 = requireGoogleAccountEmail(googleIdTokenCredential),
password = "",
imapServer = GmailConstants.GMAIL_IMAP_SERVER,
imapPort = GmailConstants.GMAIL_IMAP_PORT,
Expand Down Expand Up @@ -354,5 +353,10 @@ data class AccountEntity(
const val ACCOUNT_TYPE_GOOGLE = "com.google"
const val ACCOUNT_TYPE_OUTLOOK = "outlook.com"
const val ACCOUNT_TYPE_UNKNOWN = "unknown"

private fun requireGoogleAccountEmail(credential: GoogleIdTokenCredential): String =
requireNotNull(credential.email?.takeIf { it.isNotBlank() }) {
"Google ID token does not contain an email"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
)
)
}
Loading
Loading