From 5cecb66e8287905483552033b13aa3a5818b53d2 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Wed, 17 Jun 2026 16:56:48 -0600 Subject: [PATCH 01/20] @W-22699714: [Android] Improve error handling at code exchange Add user-friendly 'app blocked' toast when code exchange fails with client_blocked error. Follows same pattern as existing Lightning URL error handling in LoginActivity.onAuthFlowError(). - Add sf__app_blocked_error string resource - Add isClientBlocked check + when expression for toast message - Add 3 doCodeExchange error path tests in LoginViewModelMockTest - Add 3 onAuthFlowError integration tests in LoginActivityAuthErrorTest --- libs/SalesforceSDK/res/values/sf__strings.xml | 1 + .../salesforce/androidsdk/ui/LoginActivity.kt | 13 +- .../SalesforceSDKTest/AndroidManifest.xml | 5 + .../auth/LoginActivityAuthErrorTest.kt | 226 ++++++++++++++++++ .../androidsdk/auth/LoginViewModelMockTest.kt | 101 ++++++++ 5 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml index dd759506a4..ec46f7ee23 100644 --- a/libs/SalesforceSDK/res/values/sf__strings.xml +++ b/libs/SalesforceSDK/res/values/sf__strings.xml @@ -11,6 +11,7 @@ Authentication only allowed from managed device. JWT authentication error. Please try again. Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead. + This app has been blocked. Contact your administrator for assistance. SSL error: %s. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 4069244d45..ad4c2c314c 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -109,6 +109,7 @@ import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error +import com.salesforce.androidsdk.R.string.sf__app_blocked_error import com.salesforce.androidsdk.R.string.sf__lightning_url_code_exchange_error import com.salesforce.androidsdk.R.string.sf__login_with_biometric import com.salesforce.androidsdk.R.string.sf__screen_lock_error @@ -125,6 +126,7 @@ import com.salesforce.androidsdk.app.Features.FEATURE_WELCOME_DISCOVERY_LOGIN import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.auth.HttpAccess +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens @@ -584,6 +586,8 @@ open class LoginActivity : FragmentActivity() { ) viewModel.clearCookies() + val isClientBlocked = e is OAuthFailedException + && e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR val isLightningTokenEndpointFailure = e is OAuthFailedException && e.tokenErrorResponse.error == "unsupported_grant_type" && viewModel.selectedServer.value?.contains(".lightning.") == true @@ -592,11 +596,12 @@ open class LoginActivity : FragmentActivity() { } // Displays the error in a toast, clears cookies and reloads the login page runOnUiThread { - if (isLightningTokenEndpointFailure) { - makeText(this, getString(sf__lightning_url_code_exchange_error), LENGTH_LONG).show() - } else { - makeText(this, "$error : $errorDesc", LENGTH_LONG).show() + val toastMessage = when { + isClientBlocked -> getString(sf__app_blocked_error) + isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error) + else -> "$error : $errorDesc" } + makeText(this, toastMessage, LENGTH_LONG).show() viewModel.reloadWebView() } } diff --git a/libs/test/SalesforceSDKTest/AndroidManifest.xml b/libs/test/SalesforceSDKTest/AndroidManifest.xml index 578ceabb97..76fce56b14 100644 --- a/libs/test/SalesforceSDKTest/AndroidManifest.xml +++ b/libs/test/SalesforceSDKTest/AndroidManifest.xml @@ -20,6 +20,11 @@ + + + (relaxed = true) + every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR + every { tokenErrorResponse.errorDescription } returns "App is blocked by admin" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + val latch = CountDownLatch(1) + var receivedIntent: Intent? = null + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + receivedIntent = intent + latch.countDown() + } + } + + val context: Context = getApplicationContext() + context.registerReceiver( + receiver, + IntentFilter(AUTHENTICATION_FAILED_INTENT), + Context.RECEIVER_EXPORTED + ) + + try { + launch( + Intent(context, TestLoginActivity::class.java) + ).use { activityScenario -> + activityScenario.onActivity { activity -> + activity.viewModel.onWebServerFlowComplete( + "test_code", + { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, + { }, + ) + } + + assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS)) + assertNotNull(receivedIntent) + assertEquals(403, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0)) + assertEquals(CLIENT_BLOCKED_ERROR, receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT)) + assertEquals("App is blocked by admin", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT)) + } + } finally { + context.unregisterReceiver(receiver) + } + } + + @Test + fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() { + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR + every { tokenErrorResponse.errorDescription } returns "App is blocked" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + launch( + Intent(getApplicationContext(), TestLoginActivity::class.java) + ).use { activityScenario -> + activityScenario.onActivity { activity -> + activity.viewModel.onWebServerFlowComplete( + "test_code", + { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, + { }, + ) + } + + // Allow time for the coroutine + runOnUiThread to complete + Thread.sleep(500) + + activityScenario.onActivity { activity -> + val expectedMessage = activity.getString( + com.salesforce.androidsdk.R.string.sf__app_blocked_error + ) + assertEquals( + "This app has been blocked. Contact your administrator for assistance.", + expectedMessage + ) + } + } + } + + @Test + fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() { + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns "invalid_grant" + every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + val oauthException = OAuthFailedException(tokenErrorResponse, 400) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + val latch = CountDownLatch(1) + var receivedIntent: Intent? = null + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + receivedIntent = intent + latch.countDown() + } + } + + val context: Context = getApplicationContext() + context.registerReceiver( + receiver, + IntentFilter(AUTHENTICATION_FAILED_INTENT), + Context.RECEIVER_EXPORTED + ) + + try { + launch( + Intent(context, TestLoginActivity::class.java) + ).use { activityScenario -> + activityScenario.onActivity { activity -> + activity.viewModel.onWebServerFlowComplete( + "test_code", + { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, + { }, + ) + } + + assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS)) + assertNotNull(receivedIntent) + assertEquals(400, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0)) + assertEquals("invalid_grant", receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT)) + assertEquals("Expired authorization code", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT)) + } + } finally { + context.unregisterReceiver(receiver) + } + } +} diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index e8265a6051..bda7583515 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -787,6 +787,107 @@ class LoginViewModelMockTest { // endregion + // region doCodeExchange Error Path Tests + + @Test + fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns OAuth2.CLIENT_BLOCKED_ERROR + every { tokenErrorResponse.errorDescription } returns "App is blocked" + val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + Thread.sleep(200) + + verify { + mockOnError("Token Request Error", any(), oauthException) + } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + + val ioException = java.io.IOException("Network error") + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws ioException + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + Thread.sleep(200) + + verify { + mockOnError("Token Request Error", "Network error", ioException) + } + } + + @Test + fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking { + val testCode = "test_auth_code" + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + + val spyViewModel = spyk(viewModel) + + // Force OAuth2 class initialization before mocking + OAuth2.TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + + val tokenErrorResponse = mockk(relaxed = true) + every { tokenErrorResponse.error } returns "invalid_grant" + every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400) + + every { + OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + } throws oauthException + + coEvery { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } just runs + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + Thread.sleep(100) + + spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + Thread.sleep(200) + + coVerify(exactly = 0) { + spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) + } + } + + // endregion + // region showBiometricAuthenticationButton Tests @Test From 0c9a8b8d80d63e78ca142bc508ddb02a5ea07a8e Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Wed, 17 Jun 2026 17:46:13 -0600 Subject: [PATCH 02/20] fix(test): correct MockK static mock pattern for OAuth2.exchangeCode TokenErrorResponse.error and .errorDescription are public Java fields, not methods. MockK's every { } block only intercepts method calls, so using every { mock.error } returns value triggers "Missing mocked calls inside every { ... } block". Fix by directly assigning the fields on the relaxed mock instance instead. --- .../androidsdk/auth/LoginActivityAuthErrorTest.kt | 12 ++++++------ .../androidsdk/auth/LoginViewModelMockTest.kt | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt index d0fef89ae8..6b37ed60c7 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt @@ -88,8 +88,8 @@ class LoginActivityAuthErrorTest { @Test fun onAuthFlowError_givenClientBlocked_broadcastsWithCorrectExtras() { val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR - every { tokenErrorResponse.errorDescription } returns "App is blocked by admin" + tokenErrorResponse.error = CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked by admin" val oauthException = OAuthFailedException(tokenErrorResponse, 403) every { @@ -139,8 +139,8 @@ class LoginActivityAuthErrorTest { @Test fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() { val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR - every { tokenErrorResponse.errorDescription } returns "App is blocked" + tokenErrorResponse.error = CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked" val oauthException = OAuthFailedException(tokenErrorResponse, 403) every { @@ -176,8 +176,8 @@ class LoginActivityAuthErrorTest { @Test fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() { val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns "invalid_grant" - every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + tokenErrorResponse.error = "invalid_grant" + tokenErrorResponse.errorDescription = "Expired authorization code" val oauthException = OAuthFailedException(tokenErrorResponse, 400) every { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index bda7583515..be59e4ed77 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -802,8 +802,8 @@ class LoginViewModelMockTest { mockkStatic(OAuth2::class) val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns OAuth2.CLIENT_BLOCKED_ERROR - every { tokenErrorResponse.errorDescription } returns "App is blocked" + tokenErrorResponse.error = OAuth2.CLIENT_BLOCKED_ERROR + tokenErrorResponse.errorDescription = "App is blocked" val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403) every { @@ -863,8 +863,8 @@ class LoginViewModelMockTest { mockkStatic(OAuth2::class) val tokenErrorResponse = mockk(relaxed = true) - every { tokenErrorResponse.error } returns "invalid_grant" - every { tokenErrorResponse.errorDescription } returns "Expired authorization code" + tokenErrorResponse.error = "invalid_grant" + tokenErrorResponse.errorDescription = "Expired authorization code" val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400) every { From 2da22da120fdc391bf171ad5e76f1c2e69c58528 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 12:55:34 -0600 Subject: [PATCH 03/20] =?UTF-8?q?Remove=20LoginActivityAuthErrorTest=20?= =?UTF-8?q?=E2=80=94=20fragile=20Activity-based=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing onAuthFlowError via ActivityScenario.launch couples to the entire SDK initialization chain and uses Thread.sleep for timing. The ViewModel-level tests in LoginViewModelMockTest adequately cover the error propagation path without the fragility. --- .../SalesforceSDKTest/AndroidManifest.xml | 5 - .../auth/LoginActivityAuthErrorTest.kt | 226 ------------------ 2 files changed, 231 deletions(-) delete mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginActivityAuthErrorTest.kt diff --git a/libs/test/SalesforceSDKTest/AndroidManifest.xml b/libs/test/SalesforceSDKTest/AndroidManifest.xml index 76fce56b14..578ceabb97 100644 --- a/libs/test/SalesforceSDKTest/AndroidManifest.xml +++ b/libs/test/SalesforceSDKTest/AndroidManifest.xml @@ -20,11 +20,6 @@ - - - (relaxed = true) - tokenErrorResponse.error = CLIENT_BLOCKED_ERROR - tokenErrorResponse.errorDescription = "App is blocked by admin" - val oauthException = OAuthFailedException(tokenErrorResponse, 403) - - every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) - } throws oauthException - - val latch = CountDownLatch(1) - var receivedIntent: Intent? = null - - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - receivedIntent = intent - latch.countDown() - } - } - - val context: Context = getApplicationContext() - context.registerReceiver( - receiver, - IntentFilter(AUTHENTICATION_FAILED_INTENT), - Context.RECEIVER_EXPORTED - ) - - try { - launch( - Intent(context, TestLoginActivity::class.java) - ).use { activityScenario -> - activityScenario.onActivity { activity -> - activity.viewModel.onWebServerFlowComplete( - "test_code", - { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, - { }, - ) - } - - assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS)) - assertNotNull(receivedIntent) - assertEquals(403, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0)) - assertEquals(CLIENT_BLOCKED_ERROR, receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT)) - assertEquals("App is blocked by admin", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT)) - } - } finally { - context.unregisterReceiver(receiver) - } - } - - @Test - fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() { - val tokenErrorResponse = mockk(relaxed = true) - tokenErrorResponse.error = CLIENT_BLOCKED_ERROR - tokenErrorResponse.errorDescription = "App is blocked" - val oauthException = OAuthFailedException(tokenErrorResponse, 403) - - every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) - } throws oauthException - - launch( - Intent(getApplicationContext(), TestLoginActivity::class.java) - ).use { activityScenario -> - activityScenario.onActivity { activity -> - activity.viewModel.onWebServerFlowComplete( - "test_code", - { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, - { }, - ) - } - - // Allow time for the coroutine + runOnUiThread to complete - Thread.sleep(500) - - activityScenario.onActivity { activity -> - val expectedMessage = activity.getString( - com.salesforce.androidsdk.R.string.sf__app_blocked_error - ) - assertEquals( - "This app has been blocked. Contact your administrator for assistance.", - expectedMessage - ) - } - } - } - - @Test - fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() { - val tokenErrorResponse = mockk(relaxed = true) - tokenErrorResponse.error = "invalid_grant" - tokenErrorResponse.errorDescription = "Expired authorization code" - val oauthException = OAuthFailedException(tokenErrorResponse, 400) - - every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) - } throws oauthException - - val latch = CountDownLatch(1) - var receivedIntent: Intent? = null - - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - receivedIntent = intent - latch.countDown() - } - } - - val context: Context = getApplicationContext() - context.registerReceiver( - receiver, - IntentFilter(AUTHENTICATION_FAILED_INTENT), - Context.RECEIVER_EXPORTED - ) - - try { - launch( - Intent(context, TestLoginActivity::class.java) - ).use { activityScenario -> - activityScenario.onActivity { activity -> - activity.viewModel.onWebServerFlowComplete( - "test_code", - { error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) }, - { }, - ) - } - - assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS)) - assertNotNull(receivedIntent) - assertEquals(400, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0)) - assertEquals("invalid_grant", receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT)) - assertEquals("Expired authorization code", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT)) - } - } finally { - context.unregisterReceiver(receiver) - } - } -} From d5adffbe74042be2d6484ddf2011af4e9a7d0d74 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 12:59:25 -0600 Subject: [PATCH 04/20] Rename toastMessage to message in onAuthFlowError --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index ad4c2c314c..cab8792c8b 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -596,12 +596,12 @@ open class LoginActivity : FragmentActivity() { } // Displays the error in a toast, clears cookies and reloads the login page runOnUiThread { - val toastMessage = when { + val message = when { isClientBlocked -> getString(sf__app_blocked_error) isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error) else -> "$error : $errorDesc" } - makeText(this, toastMessage, LENGTH_LONG).show() + makeText(this, message, LENGTH_LONG).show() viewModel.reloadWebView() } } From 519d32c05d7b22b2f42f40356190deba4309a4cc Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:08:25 -0600 Subject: [PATCH 05/20] Remove redundant semicolon in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index cab8792c8b..86beb0dd3d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -941,7 +941,7 @@ open class LoginActivity : FragmentActivity() { // Set welcome discovery feature flag if applicable if (isLoginWithWelcomeDiscovery(intent)) { SalesforceSDKManager.getInstance() - .registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN); + .registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN) } else { SalesforceSDKManager.getInstance().unregisterUsedAppFeature( From 96bff100a170a962ca77ed806aba98eb9cb7e009 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:09:15 -0600 Subject: [PATCH 06/20] Remove redundant semicolon in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 86beb0dd3d..d6c58fdb44 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -230,7 +230,7 @@ open class LoginActivity : FragmentActivity() { } // Private variables - private var baseUserAgentString = ""; + private var baseUserAgentString = "" private var wasBackgrounded = false private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var accountAuthenticatorResult: Bundle? = null From 96148a2531b5b4d1ee36393422008cd510b658b3 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:10:02 -0600 Subject: [PATCH 07/20] Remove redundant semicolon in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index d6c58fdb44..8118c23200 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -946,7 +946,7 @@ open class LoginActivity : FragmentActivity() { else { SalesforceSDKManager.getInstance().unregisterUsedAppFeature( FEATURE_WELCOME_DISCOVERY_LOGIN - ); + ) } // Re-apply user agent to WebView From f1b65ef49db81eb83f7e1c74ff0c2c7ec3033130 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:11:23 -0600 Subject: [PATCH 08/20] Remove redundant return in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 8118c23200..4af9490a2a 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -1130,7 +1130,7 @@ open class LoginActivity : FragmentActivity() { loginHint = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_LOGIN_HINT) ?: return false, loginHost = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_MY_DOMAIN)?.toUri()?.host ?: return false ) - return true + true } else false } From c9e9693f5e6e6ee0b08c52a45a785634cf0f4fa2 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:16:37 -0600 Subject: [PATCH 09/20] Fix KDoc @param name mismatch in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 4af9490a2a..97f146081b 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -1539,7 +1539,7 @@ open class LoginActivity : FragmentActivity() { /** * Determines if the provided URL has the Salesforce Welcome Discovery * path. - * @param url The URL to examine for the Salesforce Welcome Discovery + * @param uri The URL to examine for the Salesforce Welcome Discovery * path * @return Boolean true if the URL has the Salesforce Welcome Discovery * path or false otherwise From cd508735dbf1336d3b641c7f7daab2f4eff25403 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:17:54 -0600 Subject: [PATCH 10/20] Fix KDoc @param name mismatch in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 97f146081b..37091a27bb 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -1554,7 +1554,7 @@ open class LoginActivity : FragmentActivity() { * Determines if the provided URL has the Salesforce Welcome Discovery * path and parameters for mobile callback. The client id (consumer * key) of the URL must match the boot config's consumer key. - * @param url The URL to examine for the Salesforce Welcome Discovery + * @param uri The URL to examine for the Salesforce Welcome Discovery * path and parameters for mobile callback * @return Boolean true if the URL has the Salesforce Welcome Discovery * path and parameters for mobile callback and matches the boot config's From 54f68fa4eac1dc3ec69af890b208dd05fbb47b5a Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:19:00 -0600 Subject: [PATCH 11/20] Fix KDoc @param name mismatch in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 37091a27bb..b715c3690e 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -1580,7 +1580,7 @@ open class LoginActivity : FragmentActivity() { * Determines if the provided URL has the Salesforce Welcome Discovery * path and parameters for mobile callback. The client id (consumer * key) of the URL must match the boot config's consumer key. - * @param url The URL to examine for the Salesforce Welcome Discovery + * @param uri The URL to examine for the Salesforce Welcome Discovery * path and parameters for mobile callback * @return Boolean true if the URL has the Salesforce Welcome Discovery * path and parameters for mobile callback and matches the boot config's From 9824a0e8b05601dcdebcb6103a54b9a7b2abfb67 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:19:38 -0600 Subject: [PATCH 12/20] Remove redundant inner modifier in LoginActivity --- .../src/com/salesforce/androidsdk/ui/LoginActivity.kt | 2 +- .../src/com/salesforce/androidsdk/ui/LoginActivityTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index b715c3690e..4465f90197 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -1659,7 +1659,7 @@ open class LoginActivity : FragmentActivity() { * Activity result callback for the "Login for Admin" custom tab. */ @VisibleForTesting - internal inner class AdminCustomTabActivityResult : ActivityResultCallback { + internal class AdminCustomTabActivityResult : ActivityResultCallback { override fun onActivityResult(result: ActivityResult) { // Intentional no-op: keep the existing WebView visible on cancel. } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index 1e96ba1f7d..5569bc3146 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -129,7 +129,7 @@ class LoginActivityTest { val activity = mockk(relaxed = true) every { activity.viewModel } returns viewModel - val adminResult = activity.AdminCustomTabActivityResult() + val adminResult = LoginActivity.AdminCustomTabActivityResult() adminResult.onActivityResult(ActivityResult(RESULT_CANCELED, Intent())) // Contrast with CustomTabActivityResult which calls these on cancel; the admin From d4726564da499685977a159507f1b3f968ab2b54 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:28:00 -0600 Subject: [PATCH 13/20] Prefer static imports in doCodeExchange tests --- .../androidsdk/auth/LoginViewModelMockTest.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index be59e4ed77..7924ccec0d 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -35,11 +35,15 @@ import com.salesforce.androidsdk.accounts.UserAccountBuilder import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.androidsdk.accounts.UserAccountTest import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR +import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse +import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.BiometricAuthenticationManager import com.salesforce.androidsdk.ui.LoginViewModel +import java.io.IOException import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -801,10 +805,10 @@ class LoginViewModelMockTest { OAuth2.TIMESTAMP_FORMAT mockkStatic(OAuth2::class) - val tokenErrorResponse = mockk(relaxed = true) - tokenErrorResponse.error = OAuth2.CLIENT_BLOCKED_ERROR + val tokenErrorResponse = mockk(relaxed = true) + tokenErrorResponse.error = CLIENT_BLOCKED_ERROR tokenErrorResponse.errorDescription = "App is blocked" - val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403) + val oauthException = OAuthFailedException(tokenErrorResponse, 403) every { OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) @@ -833,7 +837,7 @@ class LoginViewModelMockTest { OAuth2.TIMESTAMP_FORMAT mockkStatic(OAuth2::class) - val ioException = java.io.IOException("Network error") + val ioException = IOException("Network error") every { OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) @@ -862,10 +866,10 @@ class LoginViewModelMockTest { OAuth2.TIMESTAMP_FORMAT mockkStatic(OAuth2::class) - val tokenErrorResponse = mockk(relaxed = true) + val tokenErrorResponse = mockk(relaxed = true) tokenErrorResponse.error = "invalid_grant" tokenErrorResponse.errorDescription = "Expired authorization code" - val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400) + val oauthException = OAuthFailedException(tokenErrorResponse, 400) every { OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) From 0b8b79dc2047a717defc5cbf96630fcd4c5fa9ee Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:31:30 -0600 Subject: [PATCH 14/20] Optimize imports --- .../salesforce/androidsdk/ui/LoginActivity.kt | 2 +- .../androidsdk/auth/LoginViewModelMockTest.kt | 74 +++++++++---------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 4465f90197..7297261e3f 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -106,10 +106,10 @@ import com.salesforce.androidsdk.R.color.sf__background import com.salesforce.androidsdk.R.color.sf__background_dark import com.salesforce.androidsdk.R.drawable.sf__action_back import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code +import com.salesforce.androidsdk.R.string.sf__app_blocked_error import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error -import com.salesforce.androidsdk.R.string.sf__app_blocked_error import com.salesforce.androidsdk.R.string.sf__lightning_url_code_exchange_error import com.salesforce.androidsdk.R.string.sf__login_with_biometric import com.salesforce.androidsdk.R.string.sf__screen_lock_error diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index 7924ccec0d..abefa9e886 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -43,7 +43,6 @@ import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.BiometricAuthenticationManager import com.salesforce.androidsdk.ui.LoginViewModel -import java.io.IOException import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -64,6 +63,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.IOException /** * Tests for LoginViewModel that require mocking. @@ -91,11 +91,11 @@ class LoginViewModelMockTest { // Create view model after mocking viewModel = LoginViewModel(bootConfig) - + // This is required for the LiveData to actually update during the test viewModel.selectedServer.observeForever { } viewModel.loginUrl.observeForever { } - + // Give the LiveData sources time to propagate Thread.sleep(100) } @@ -110,7 +110,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_CallsAuthenticationUtilities_WithCorrectParameters() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + // Mock the function to do nothing (just capture parameters) coEvery { onAuthFlowComplete( @@ -136,20 +136,20 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + // Create test data val testServer = "https://test.salesforce.com" val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set up the view model state viewModel.selectedServer.value = testServer Thread.sleep(100) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) - + // Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters coVerify { onAuthFlowComplete( @@ -180,7 +180,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_CallsAuthenticationUtilitiesSuccessfully() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -205,15 +205,15 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set up the view model state viewModel.selectedServer.value = "https://test.salesforce.com" Thread.sleep(100) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) @@ -248,7 +248,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_ResetsAuthCodeForJwtFlow() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -273,22 +273,22 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set up the view model state with JWT flow viewModel.selectedServer.value = "https://test.salesforce.com" viewModel.authCodeForJwtFlow = "test_jwt_auth_code" Thread.sleep(100) - + // Verify authCodeForJwtFlow is set assertNotNull("authCodeForJwtFlow should be set before call", viewModel.authCodeForJwtFlow) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) - + // Verify authCodeForJwtFlow is reset to null assertNull("authCodeForJwtFlow should be null after call", viewModel.authCodeForJwtFlow) } @@ -296,7 +296,7 @@ class LoginViewModelMockTest { @Test fun onAuthFlowComplete_UsesEmptyString_WhenSelectedServerIsNull() = runBlocking { // Mock the AuthenticationUtilities.onAuthFlowComplete function - + coEvery { onAuthFlowComplete( tokenResponse = any(), @@ -321,18 +321,18 @@ class LoginViewModelMockTest { handleDuplicateUserAccount = any(), ) } returns Unit - + val mockTokenResponse = mockk(relaxed = true) val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Set selectedServer to null viewModel.selectedServer.value = null Thread.sleep(100) - + // Call the method under test viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess) - + // Verify empty string is used when selectedServer is null coVerify { onAuthFlowComplete( @@ -366,10 +366,10 @@ class LoginViewModelMockTest { val testCode = "test_auth_code_123" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -378,13 +378,13 @@ class LoginViewModelMockTest { // Set up the view model state spyViewModel.selectedServer.value = testServer Thread.sleep(100) - + // Call the method under test spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) - + // Give time for the coroutine to execute Thread.sleep(200) - + // Verify doCodeExchange was called with correct parameters coVerify { spyViewModel.doCodeExchange( @@ -449,10 +449,10 @@ class LoginViewModelMockTest { val testCode = "test_auth_code_123" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -461,10 +461,10 @@ class LoginViewModelMockTest { // Set up front door bridge spyViewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier) Thread.sleep(100) - + // Call the method under test spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess) - + // Give time for the coroutine to execute Thread.sleep(200) @@ -485,10 +485,10 @@ class LoginViewModelMockTest { val testServer = "https://test.salesforce.com" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - + // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) @@ -497,10 +497,10 @@ class LoginViewModelMockTest { // Set up the view model state spyViewModel.selectedServer.value = testServer Thread.sleep(100) - + // Call with null code spyViewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess) - + // Give time for the coroutine to execute Thread.sleep(200) @@ -593,7 +593,7 @@ class LoginViewModelMockTest { // Create a spy of viewModel to verify and mock doCodeExchange val spyViewModel = spyk(viewModel) - + // Mock doCodeExchange to prevent actual execution coEvery { spyViewModel.doCodeExchange(any(), any(), any(), any(), any()) From 16fa5e60fb980769437be757d302de3bc9567f29 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:33:17 -0600 Subject: [PATCH 15/20] Use static import for TIMESTAMP_FORMAT in tests --- .../androidsdk/auth/LoginViewModelMockTest.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index abefa9e886..8a0627b084 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -37,6 +37,7 @@ import com.salesforce.androidsdk.accounts.UserAccountTest import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException +import com.salesforce.androidsdk.auth.OAuth2.TIMESTAMP_FORMAT import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse import com.salesforce.androidsdk.config.BootConfig @@ -638,7 +639,7 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) @@ -686,7 +687,7 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) @@ -740,7 +741,7 @@ class LoginViewModelMockTest { val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true) // Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) @@ -802,7 +803,7 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) val tokenErrorResponse = mockk(relaxed = true) @@ -834,7 +835,7 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) val ioException = IOException("Network error") @@ -863,7 +864,7 @@ class LoginViewModelMockTest { val spyViewModel = spyk(viewModel) // Force OAuth2 class initialization before mocking - OAuth2.TIMESTAMP_FORMAT + TIMESTAMP_FORMAT mockkStatic(OAuth2::class) val tokenErrorResponse = mockk(relaxed = true) From c9f33dfde2bc9aa8be7c83ad7a0fff143021e175 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:40:26 -0600 Subject: [PATCH 16/20] Remove unnecessary Thread.sleep from doCodeExchange error tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doCodeExchange is a suspend function — it completes before returning within runBlocking. Fixed delays are unnecessary and a flakiness risk. --- .../salesforce/androidsdk/auth/LoginViewModelMockTest.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index 8a0627b084..f878504c4a 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -816,10 +816,7 @@ class LoginViewModelMockTest { } throws oauthException spyViewModel.selectedServer.value = "https://test.salesforce.com" - Thread.sleep(100) - spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) - Thread.sleep(200) verify { mockOnError("Token Request Error", any(), oauthException) @@ -845,10 +842,7 @@ class LoginViewModelMockTest { } throws ioException spyViewModel.selectedServer.value = "https://test.salesforce.com" - Thread.sleep(100) - spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) - Thread.sleep(200) verify { mockOnError("Token Request Error", "Network error", ioException) @@ -881,10 +875,7 @@ class LoginViewModelMockTest { } just runs spyViewModel.selectedServer.value = "https://test.salesforce.com" - Thread.sleep(100) - spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) - Thread.sleep(200) coVerify(exactly = 0) { spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) From 05da06f3685e07345d6ed0bcae4e9f7a8c6d5a07 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:46:30 -0600 Subject: [PATCH 17/20] Use static import for exchangeCode in tests --- .../androidsdk/auth/LoginViewModelMockTest.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index f878504c4a..6e5fd4295e 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -40,6 +40,7 @@ import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TIMESTAMP_FORMAT import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse +import com.salesforce.androidsdk.auth.OAuth2.exchangeCode import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.BiometricAuthenticationManager @@ -642,7 +643,7 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Mock doCodeExchange to prevent actual execution @@ -690,7 +691,7 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Mock doCodeExchange to prevent actual execution @@ -744,7 +745,7 @@ class LoginViewModelMockTest { TIMESTAMP_FORMAT mockkStatic(OAuth2::class) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } returns mockTokenResponse // Spy so we can short-circuit account creation, leaving exchangeCode as the observable. @@ -779,7 +780,7 @@ class LoginViewModelMockTest { // Token exchange must be performed with MIGRATION credentials. verify { - OAuth2.exchangeCode( + exchangeCode( /* httpAccessor = */ any(), /* loginServer = */ any(), /* clientId = */ migrationConsumerKey, @@ -812,7 +813,7 @@ class LoginViewModelMockTest { val oauthException = OAuthFailedException(tokenErrorResponse, 403) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } throws oauthException spyViewModel.selectedServer.value = "https://test.salesforce.com" @@ -838,7 +839,7 @@ class LoginViewModelMockTest { val ioException = IOException("Network error") every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } throws ioException spyViewModel.selectedServer.value = "https://test.salesforce.com" @@ -867,7 +868,7 @@ class LoginViewModelMockTest { val oauthException = OAuthFailedException(tokenErrorResponse, 400) every { - OAuth2.exchangeCode(any(), any(), any(), any(), any(), any()) + exchangeCode(any(), any(), any(), any(), any(), any()) } throws oauthException coEvery { From fc0715732cdf55cdc22c07a5dd3a1765c087515b Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 13:56:09 -0600 Subject: [PATCH 18/20] Strengthen doCodeExchange error path tests - Extract setupExchangeCodeMock helper to reduce duplication - Verify mockOnSuccess is never called on error paths - Verify mockOnError IS called in the neverCallsOnAuthFlowComplete test --- .../androidsdk/auth/LoginViewModelMockTest.kt | 59 +++++++------------ 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index 6e5fd4295e..74ad4fb395 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -795,89 +795,70 @@ class LoginViewModelMockTest { // region doCodeExchange Error Path Tests + private fun setupExchangeCodeMock(throws: Throwable) { + TIMESTAMP_FORMAT + mockkStatic(OAuth2::class) + every { + exchangeCode(any(), any(), any(), any(), any(), any()) + } throws throws + } + @Test fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking { - val testCode = "test_auth_code" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - val spyViewModel = spyk(viewModel) - // Force OAuth2 class initialization before mocking - TIMESTAMP_FORMAT - mockkStatic(OAuth2::class) - val tokenErrorResponse = mockk(relaxed = true) tokenErrorResponse.error = CLIENT_BLOCKED_ERROR tokenErrorResponse.errorDescription = "App is blocked" val oauthException = OAuthFailedException(tokenErrorResponse, 403) - - every { - exchangeCode(any(), any(), any(), any(), any(), any()) - } throws oauthException + setupExchangeCodeMock(oauthException) spyViewModel.selectedServer.value = "https://test.salesforce.com" - spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) - verify { - mockOnError("Token Request Error", any(), oauthException) - } + verify { mockOnError("Token Request Error", any(), oauthException) } + verify(exactly = 0) { mockOnSuccess(any()) } } @Test fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking { - val testCode = "test_auth_code" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - val spyViewModel = spyk(viewModel) - // Force OAuth2 class initialization before mocking - TIMESTAMP_FORMAT - mockkStatic(OAuth2::class) - val ioException = IOException("Network error") - - every { - exchangeCode(any(), any(), any(), any(), any(), any()) - } throws ioException + setupExchangeCodeMock(ioException) spyViewModel.selectedServer.value = "https://test.salesforce.com" - spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) - verify { - mockOnError("Token Request Error", "Network error", ioException) - } + verify { mockOnError("Token Request Error", "Network error", ioException) } + verify(exactly = 0) { mockOnSuccess(any()) } } @Test fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking { - val testCode = "test_auth_code" val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) - val spyViewModel = spyk(viewModel) - // Force OAuth2 class initialization before mocking - TIMESTAMP_FORMAT - mockkStatic(OAuth2::class) - val tokenErrorResponse = mockk(relaxed = true) tokenErrorResponse.error = "invalid_grant" tokenErrorResponse.errorDescription = "Expired authorization code" val oauthException = OAuthFailedException(tokenErrorResponse, 400) - - every { - exchangeCode(any(), any(), any(), any(), any(), any()) - } throws oauthException + setupExchangeCodeMock(oauthException) coEvery { spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) } just runs spyViewModel.selectedServer.value = "https://test.salesforce.com" - spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess) + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) + verify { mockOnError("Token Request Error", any(), oauthException) } + verify(exactly = 0) { mockOnSuccess(any()) } coVerify(exactly = 0) { spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any()) } From 47436fbf8960f7b593aa635b458a1ba23aac7864 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 14:13:51 -0600 Subject: [PATCH 19/20] Update app blocked error message to reference verification Reworded to reflect that the block is due to app integrity verification failure rather than a generic administrative block. --- libs/SalesforceSDK/res/values/sf__strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml index ec46f7ee23..5ebe981dd5 100644 --- a/libs/SalesforceSDK/res/values/sf__strings.xml +++ b/libs/SalesforceSDK/res/values/sf__strings.xml @@ -11,7 +11,7 @@ Authentication only allowed from managed device. JWT authentication error. Please try again. Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead. - This app has been blocked. Contact your administrator for assistance. + This app could not be verified. Contact support. SSL error: %s. From 7c29e7703f09731d6577456c313d6ea7d851c1c6 Mon Sep 17 00:00:00 2001 From: "Eric C. Johnson" Date: Thu, 18 Jun 2026 14:18:40 -0600 Subject: [PATCH 20/20] Also recognize CLIENT_BLOCKED_RETRY_ERROR at code exchange Per reviewer feedback, handle client_blocked_retry the same as client_blocked for now. Retry logic may be added later. --- .../salesforce/androidsdk/ui/LoginActivity.kt | 4 +++- .../androidsdk/auth/LoginViewModelMockTest.kt | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 7297261e3f..481582ab82 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -127,6 +127,7 @@ import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_RETRY_ERROR import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens @@ -587,7 +588,8 @@ open class LoginActivity : FragmentActivity() { viewModel.clearCookies() val isClientBlocked = e is OAuthFailedException - && e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR + && (e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR + || e.tokenErrorResponse.error == CLIENT_BLOCKED_RETRY_ERROR) val isLightningTokenEndpointFailure = e is OAuthFailedException && e.tokenErrorResponse.error == "unsupported_grant_type" && viewModel.selectedServer.value?.contains(".lightning.") == true diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt index 74ad4fb395..56e77380ce 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt @@ -36,6 +36,7 @@ import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.androidsdk.accounts.UserAccountTest import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR +import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_RETRY_ERROR import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException import com.salesforce.androidsdk.auth.OAuth2.TIMESTAMP_FORMAT import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse @@ -822,6 +823,25 @@ class LoginViewModelMockTest { verify(exactly = 0) { mockOnSuccess(any()) } } + @Test + fun doCodeExchange_whenExchangeCodeThrowsClientBlockedRetry_callsOnAuthFlowError() = runBlocking { + val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true) + val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true) + val spyViewModel = spyk(viewModel) + + val tokenErrorResponse = mockk(relaxed = true) + tokenErrorResponse.error = CLIENT_BLOCKED_RETRY_ERROR + tokenErrorResponse.errorDescription = "App is blocked (retry)" + val oauthException = OAuthFailedException(tokenErrorResponse, 403) + setupExchangeCodeMock(oauthException) + + spyViewModel.selectedServer.value = "https://test.salesforce.com" + spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess) + + verify { mockOnError("Token Request Error", any(), oauthException) } + verify(exactly = 0) { mockOnSuccess(any()) } + } + @Test fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking { val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)