Skip to content

Commit 5cecb66

Browse files
@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
1 parent 467d26c commit 5cecb66

5 files changed

Lines changed: 342 additions & 4 deletions

File tree

libs/SalesforceSDK/res/values/sf__strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<string name="sf__managed_app_error">Authentication only allowed from managed device.</string>
1212
<string name="sf__jwt_authentication_error">JWT authentication error. Please try again.</string>
1313
<string name="sf__lightning_url_code_exchange_error">Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead.</string>
14+
<string name="sf__app_blocked_error">This app has been blocked. Contact your administrator for assistance.</string>
1415

1516
<!-- SSL errors -->
1617
<string name="sf__ssl_error">SSL error: %s.</string>

libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code
109109
import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title
110110
import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title
111111
import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error
112+
import com.salesforce.androidsdk.R.string.sf__app_blocked_error
112113
import com.salesforce.androidsdk.R.string.sf__lightning_url_code_exchange_error
113114
import com.salesforce.androidsdk.R.string.sf__login_with_biometric
114115
import com.salesforce.androidsdk.R.string.sf__screen_lock_error
@@ -125,6 +126,7 @@ import com.salesforce.androidsdk.app.Features.FEATURE_WELCOME_DISCOVERY_LOGIN
125126
import com.salesforce.androidsdk.app.SalesforceSDKManager
126127
import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK
127128
import com.salesforce.androidsdk.auth.HttpAccess
129+
import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR
128130
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException
129131
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
130132
import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens
@@ -584,6 +586,8 @@ open class LoginActivity : FragmentActivity() {
584586
)
585587

586588
viewModel.clearCookies()
589+
val isClientBlocked = e is OAuthFailedException
590+
&& e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR
587591
val isLightningTokenEndpointFailure = e is OAuthFailedException
588592
&& e.tokenErrorResponse.error == "unsupported_grant_type"
589593
&& viewModel.selectedServer.value?.contains(".lightning.") == true
@@ -592,11 +596,12 @@ open class LoginActivity : FragmentActivity() {
592596
}
593597
// Displays the error in a toast, clears cookies and reloads the login page
594598
runOnUiThread {
595-
if (isLightningTokenEndpointFailure) {
596-
makeText(this, getString(sf__lightning_url_code_exchange_error), LENGTH_LONG).show()
597-
} else {
598-
makeText(this, "$error : $errorDesc", LENGTH_LONG).show()
599+
val toastMessage = when {
600+
isClientBlocked -> getString(sf__app_blocked_error)
601+
isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error)
602+
else -> "$error : $errorDesc"
599603
}
604+
makeText(this, toastMessage, LENGTH_LONG).show()
600605
viewModel.reloadWebView()
601606
}
602607
}

libs/test/SalesforceSDKTest/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020

2121

2222

23+
<!-- Test-only activity for LoginActivityAuthErrorTest -->
24+
<activity android:name="com.salesforce.androidsdk.auth.TestLoginActivity"
25+
android:exported="false"
26+
android:theme="@style/SalesforceSDK" />
27+
2328
<!-- Launcher screen -->
2429
<activity android:name="com.salesforce.androidsdk.MainActivity"
2530
android:label="@string/app_name"
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright (c) 2025-present, salesforce.com, inc.
3+
* All rights reserved.
4+
* Redistribution and use of this software in source and binary forms, with or
5+
* without modification, are permitted provided that the following conditions
6+
* are met:
7+
* - Redistributions of source code must retain the above copyright notice, this
8+
* list of conditions and the following disclaimer.
9+
* - Redistributions in binary form must reproduce the above copyright notice,
10+
* this list of conditions and the following disclaimer in the documentation
11+
* and/or other materials provided with the distribution.
12+
* - Neither the name of salesforce.com, inc. nor the names of its contributors
13+
* may be used to endorse or promote products derived from this software without
14+
* specific prior written permission of salesforce.com, inc.
15+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
19+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
20+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
21+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
22+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
24+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
25+
* POSSIBILITY OF SUCH DAMAGE.
26+
*/
27+
package com.salesforce.androidsdk.auth
28+
29+
import android.content.BroadcastReceiver
30+
import android.content.Context
31+
import android.content.Intent
32+
import android.content.IntentFilter
33+
import androidx.test.core.app.ActivityScenario.launch
34+
import androidx.test.core.app.ApplicationProvider.getApplicationContext
35+
import androidx.test.ext.junit.runners.AndroidJUnit4
36+
import com.salesforce.androidsdk.accounts.UserAccount
37+
import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR
38+
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException
39+
import com.salesforce.androidsdk.ui.LoginActivity
40+
import io.mockk.every
41+
import io.mockk.mockk
42+
import io.mockk.mockkStatic
43+
import io.mockk.unmockkAll
44+
import org.junit.After
45+
import org.junit.Assert.assertEquals
46+
import org.junit.Assert.assertNotNull
47+
import org.junit.Assert.assertTrue
48+
import org.junit.Before
49+
import org.junit.Test
50+
import org.junit.runner.RunWith
51+
import java.util.concurrent.CountDownLatch
52+
import java.util.concurrent.TimeUnit
53+
54+
/**
55+
* Test subclass that exposes the protected onAuthFlowError for testing.
56+
*/
57+
class TestLoginActivity : LoginActivity() {
58+
public override fun onAuthFlowError(error: String, errorDesc: String?, e: Throwable?) {
59+
super.onAuthFlowError(error, errorDesc, e)
60+
}
61+
62+
override fun onAuthFlowSuccess(userAccount: UserAccount) {
63+
// No-op for tests
64+
}
65+
}
66+
67+
@RunWith(AndroidJUnit4::class)
68+
class LoginActivityAuthErrorTest {
69+
70+
private companion object {
71+
const val AUTHENTICATION_FAILED_INTENT = "com.salesforce.auth.intent.AUTHENTICATION_ERROR"
72+
const val HTTP_ERROR_RESPONSE_CODE_INTENT = "com.salesforce.auth.intent.HTTP_RESPONSE_CODE"
73+
const val RESPONSE_ERROR_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR"
74+
const val RESPONSE_ERROR_DESCRIPTION_INTENT = "com.salesforce.auth.intent.RESPONSE_ERROR_DESCRIPTION"
75+
}
76+
77+
@Before
78+
fun setup() {
79+
OAuth2.TIMESTAMP_FORMAT
80+
mockkStatic(OAuth2::class)
81+
}
82+
83+
@After
84+
fun teardown() {
85+
unmockkAll()
86+
}
87+
88+
@Test
89+
fun onAuthFlowError_givenClientBlocked_broadcastsWithCorrectExtras() {
90+
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
91+
every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR
92+
every { tokenErrorResponse.errorDescription } returns "App is blocked by admin"
93+
val oauthException = OAuthFailedException(tokenErrorResponse, 403)
94+
95+
every {
96+
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
97+
} throws oauthException
98+
99+
val latch = CountDownLatch(1)
100+
var receivedIntent: Intent? = null
101+
102+
val receiver = object : BroadcastReceiver() {
103+
override fun onReceive(context: Context?, intent: Intent?) {
104+
receivedIntent = intent
105+
latch.countDown()
106+
}
107+
}
108+
109+
val context: Context = getApplicationContext()
110+
context.registerReceiver(
111+
receiver,
112+
IntentFilter(AUTHENTICATION_FAILED_INTENT),
113+
Context.RECEIVER_EXPORTED
114+
)
115+
116+
try {
117+
launch<TestLoginActivity>(
118+
Intent(context, TestLoginActivity::class.java)
119+
).use { activityScenario ->
120+
activityScenario.onActivity { activity ->
121+
activity.viewModel.onWebServerFlowComplete(
122+
"test_code",
123+
{ error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) },
124+
{ },
125+
)
126+
}
127+
128+
assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS))
129+
assertNotNull(receivedIntent)
130+
assertEquals(403, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0))
131+
assertEquals(CLIENT_BLOCKED_ERROR, receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT))
132+
assertEquals("App is blocked by admin", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT))
133+
}
134+
} finally {
135+
context.unregisterReceiver(receiver)
136+
}
137+
}
138+
139+
@Test
140+
fun onAuthFlowError_givenClientBlocked_showsAppBlockedToast() {
141+
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
142+
every { tokenErrorResponse.error } returns CLIENT_BLOCKED_ERROR
143+
every { tokenErrorResponse.errorDescription } returns "App is blocked"
144+
val oauthException = OAuthFailedException(tokenErrorResponse, 403)
145+
146+
every {
147+
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
148+
} throws oauthException
149+
150+
launch<TestLoginActivity>(
151+
Intent(getApplicationContext(), TestLoginActivity::class.java)
152+
).use { activityScenario ->
153+
activityScenario.onActivity { activity ->
154+
activity.viewModel.onWebServerFlowComplete(
155+
"test_code",
156+
{ error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) },
157+
{ },
158+
)
159+
}
160+
161+
// Allow time for the coroutine + runOnUiThread to complete
162+
Thread.sleep(500)
163+
164+
activityScenario.onActivity { activity ->
165+
val expectedMessage = activity.getString(
166+
com.salesforce.androidsdk.R.string.sf__app_blocked_error
167+
)
168+
assertEquals(
169+
"This app has been blocked. Contact your administrator for assistance.",
170+
expectedMessage
171+
)
172+
}
173+
}
174+
}
175+
176+
@Test
177+
fun onAuthFlowError_givenGenericOAuthError_broadcastsWithCorrectExtras() {
178+
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
179+
every { tokenErrorResponse.error } returns "invalid_grant"
180+
every { tokenErrorResponse.errorDescription } returns "Expired authorization code"
181+
val oauthException = OAuthFailedException(tokenErrorResponse, 400)
182+
183+
every {
184+
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
185+
} throws oauthException
186+
187+
val latch = CountDownLatch(1)
188+
var receivedIntent: Intent? = null
189+
190+
val receiver = object : BroadcastReceiver() {
191+
override fun onReceive(context: Context?, intent: Intent?) {
192+
receivedIntent = intent
193+
latch.countDown()
194+
}
195+
}
196+
197+
val context: Context = getApplicationContext()
198+
context.registerReceiver(
199+
receiver,
200+
IntentFilter(AUTHENTICATION_FAILED_INTENT),
201+
Context.RECEIVER_EXPORTED
202+
)
203+
204+
try {
205+
launch<TestLoginActivity>(
206+
Intent(context, TestLoginActivity::class.java)
207+
).use { activityScenario ->
208+
activityScenario.onActivity { activity ->
209+
activity.viewModel.onWebServerFlowComplete(
210+
"test_code",
211+
{ error, errorDesc, e -> activity.onAuthFlowError(error, errorDesc, e) },
212+
{ },
213+
)
214+
}
215+
216+
assertTrue("Broadcast should be received within 5 seconds", latch.await(5, TimeUnit.SECONDS))
217+
assertNotNull(receivedIntent)
218+
assertEquals(400, receivedIntent!!.getIntExtra(HTTP_ERROR_RESPONSE_CODE_INTENT, 0))
219+
assertEquals("invalid_grant", receivedIntent!!.getStringExtra(RESPONSE_ERROR_INTENT))
220+
assertEquals("Expired authorization code", receivedIntent!!.getStringExtra(RESPONSE_ERROR_DESCRIPTION_INTENT))
221+
}
222+
} finally {
223+
context.unregisterReceiver(receiver)
224+
}
225+
}
226+
}

libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,107 @@ class LoginViewModelMockTest {
787787

788788
// endregion
789789

790+
// region doCodeExchange Error Path Tests
791+
792+
@Test
793+
fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking {
794+
val testCode = "test_auth_code"
795+
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
796+
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
797+
798+
val spyViewModel = spyk(viewModel)
799+
800+
// Force OAuth2 class initialization before mocking
801+
OAuth2.TIMESTAMP_FORMAT
802+
mockkStatic(OAuth2::class)
803+
804+
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
805+
every { tokenErrorResponse.error } returns OAuth2.CLIENT_BLOCKED_ERROR
806+
every { tokenErrorResponse.errorDescription } returns "App is blocked"
807+
val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 403)
808+
809+
every {
810+
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
811+
} throws oauthException
812+
813+
spyViewModel.selectedServer.value = "https://test.salesforce.com"
814+
Thread.sleep(100)
815+
816+
spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess)
817+
Thread.sleep(200)
818+
819+
verify {
820+
mockOnError("Token Request Error", any(), oauthException)
821+
}
822+
}
823+
824+
@Test
825+
fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking {
826+
val testCode = "test_auth_code"
827+
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
828+
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
829+
830+
val spyViewModel = spyk(viewModel)
831+
832+
// Force OAuth2 class initialization before mocking
833+
OAuth2.TIMESTAMP_FORMAT
834+
mockkStatic(OAuth2::class)
835+
836+
val ioException = java.io.IOException("Network error")
837+
838+
every {
839+
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
840+
} throws ioException
841+
842+
spyViewModel.selectedServer.value = "https://test.salesforce.com"
843+
Thread.sleep(100)
844+
845+
spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess)
846+
Thread.sleep(200)
847+
848+
verify {
849+
mockOnError("Token Request Error", "Network error", ioException)
850+
}
851+
}
852+
853+
@Test
854+
fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking {
855+
val testCode = "test_auth_code"
856+
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
857+
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
858+
859+
val spyViewModel = spyk(viewModel)
860+
861+
// Force OAuth2 class initialization before mocking
862+
OAuth2.TIMESTAMP_FORMAT
863+
mockkStatic(OAuth2::class)
864+
865+
val tokenErrorResponse = mockk<OAuth2.TokenErrorResponse>(relaxed = true)
866+
every { tokenErrorResponse.error } returns "invalid_grant"
867+
every { tokenErrorResponse.errorDescription } returns "Expired authorization code"
868+
val oauthException = OAuth2.OAuthFailedException(tokenErrorResponse, 400)
869+
870+
every {
871+
OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
872+
} throws oauthException
873+
874+
coEvery {
875+
spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
876+
} just runs
877+
878+
spyViewModel.selectedServer.value = "https://test.salesforce.com"
879+
Thread.sleep(100)
880+
881+
spyViewModel.doCodeExchange(testCode, mockOnError, mockOnSuccess)
882+
Thread.sleep(200)
883+
884+
coVerify(exactly = 0) {
885+
spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
886+
}
887+
}
888+
889+
// endregion
890+
790891
// region showBiometricAuthenticationButton Tests
791892

792893
@Test

0 commit comments

Comments
 (0)