11/*
2- * Copyright (c) 2026-pffsent , salesforce.com, inc.
2+ * Copyright (c) 2026-present , salesforce.com, inc.
33 * All rights reserved.
44 * Redistribution and use of this software in source and binary forms, with or
55 * without modification, are permitted provided that the following conditions
2727package com.salesforce.androidsdk.ui
2828
2929import android.annotation.SuppressLint
30+ import android.content.Context
3031import android.graphics.Bitmap
3132import android.os.Bundle
3233import android.webkit.WebResourceRequest
@@ -37,7 +38,6 @@ import androidx.activity.compose.setContent
3738import androidx.activity.enableEdgeToEdge
3839import androidx.activity.viewModels
3940import androidx.annotation.VisibleForTesting
40- import androidx.annotation.VisibleForTesting.Companion.PROTECTED
4141import androidx.compose.animation.core.animateFloatAsState
4242import androidx.compose.animation.core.tween
4343import androidx.compose.foundation.background
@@ -82,10 +82,26 @@ import kotlinx.serialization.json.Json
8282import kotlinx.serialization.json.jsonObject
8383import kotlinx.serialization.json.jsonPrimitive
8484
85- const val HALF_ALPHA = 0.5f
85+ private const val HALF_ALPHA = 0.5f
86+
87+ // Error messages - internal for testing
88+ @VisibleForTesting
89+ internal const val ERROR_PARSE_CALLBACK_ID = " Unable to parse MigrationResult callback id."
90+ @VisibleForTesting
91+ internal const val ERROR_RETRIEVE_CALLBACK = " Unable to retrieve MigrationResult callback."
92+ @VisibleForTesting
93+ internal const val ERROR_PARSE_OAUTH_CONFIG = " Unable to parse OAuthConfig."
94+ @VisibleForTesting
95+ internal const val ERROR_BUILD_USER_ACCOUNT = " Unable to build user account."
96+ @VisibleForTesting
97+ internal const val ERROR_BUILD_REST_CLIENT = " Unable to build RestClient."
98+ @VisibleForTesting
99+ internal const val ERROR_SINGLE_ACCESS_FAILED = " Request for single access bridge url failed"
100+ @VisibleForTesting
101+ internal const val ERROR_TOKEN_INVALID_DESC = " User's existing token may be invalid."
102+
86103internal class TokenMigrationActivity : ComponentActivity () {
87104
88- @VisibleForTesting(otherwise = PROTECTED )
89105 private val viewModel: LoginViewModel
90106 by viewModels { SalesforceSDKManager .getInstance().loginViewModelFactory }
91107
@@ -94,36 +110,36 @@ internal class TokenMigrationActivity : ComponentActivity() {
94110 enableEdgeToEdge()
95111
96112 val callbackKey = intent.getStringExtra(EXTRA_CALLBACK_ID ) ? : run {
97- SalesforceSDKLogger .e(TAG , " Unable to parse MigrationResult callback id. " )
113+ SalesforceSDKLogger .e(TAG , ERROR_PARSE_CALLBACK_ID )
98114 finish()
99115 return
100116 }
101117 val resultCallback = MigrationCallbackRegistry .consume(callbackKey) ? : run {
102- SalesforceSDKLogger .e(TAG , " Unable to retrieve MigrationResult callback. " )
118+ SalesforceSDKLogger .e(TAG , ERROR_RETRIEVE_CALLBACK )
103119 finish()
104120 return
105121 }
106122
107123 // TODO: Move to non-deprecated getParcelableExtra when min API >= 33
108124 val oAuthConfig = intent.getParcelableExtra<OAuthConfig >(EXTRA_OAUTH_CONFIG ) ? : run {
109- logMigrationError(resultCallback, " Unable to parse OAuthConfig. " , null , null )
125+ logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG , null , null )
110126 return
111127 }
112128
113129 val orgId = intent.getStringExtra(EXTRA_ORG_ID )
114130 val userId = intent.getStringExtra(EXTRA_USER_ID )
115131 if ( orgId == null || userId == null ) {
116- logMigrationError(resultCallback, " Unable to parse OAuthConfig. " , null , null )
132+ logMigrationError(resultCallback, ERROR_PARSE_OAUTH_CONFIG , null , null )
117133 return
118134 }
119135 val user = UserAccountManager .getInstance().getUserFromOrgAndUserId(orgId, userId) ? : run {
120- logMigrationError(resultCallback, " Unable to build user account. " , null , null )
136+ logMigrationError(resultCallback, ERROR_BUILD_USER_ACCOUNT , null , null )
121137 return
122138 }
123139 val client = runCatching {
124140 SalesforceSDKManager .getInstance().clientManager.peekRestClient(user)
125141 }.getOrElse { e ->
126- logMigrationError(resultCallback, " Unable to build RestClient. " , null , e as ? Exception )
142+ logMigrationError(resultCallback, ERROR_BUILD_REST_CLIENT , null , e as ? Exception )
127143 return
128144 }
129145
@@ -149,8 +165,8 @@ internal class TokenMigrationActivity : ComponentActivity() {
149165 } ? : run {
150166 logMigrationError(
151167 resultCallback = resultCallback,
152- error = " Request for single access bridge url failed " ,
153- errorDesc = " User's existing token may be invalid. " ,
168+ error = ERROR_SINGLE_ACCESS_FAILED ,
169+ errorDesc = ERROR_TOKEN_INVALID_DESC ,
154170 e = null ,
155171 )
156172 return @launch
@@ -199,100 +215,105 @@ internal class TokenMigrationActivity : ComponentActivity() {
199215 frontDoorUrl : String ,
200216 resultCallback : MigrationCallbackRegistry .MigrationCallbacks ,
201217 instanceServer : String ,
202- ): WebView = WebView (this @TokenMigrationActivity).apply {
218+ ): WebView = webViewFactory (this @TokenMigrationActivity).apply {
203219 @SuppressLint(" SetJavaScriptEnabled" ) // Required by Salesforce
204220 settings.javaScriptEnabled = true
205221 settings.userAgentString = SalesforceSDKManager .getInstance().userAgent
206222 setBackgroundColor(android.graphics.Color .TRANSPARENT )
223+ webViewClient = TokenMigrationClientManager (resultCallback, instanceServer)
207224
208- // This implementation is very similar to [LoginActivity.AuthWebViewClient] but the
209- // code cannot be shared due to the heavy reliance on the ViewModel.
210- webViewClient = object : WebViewClient () {
211-
212- override fun shouldOverrideUrlLoading (
213- view : WebView ,
214- request : WebResourceRequest ,
215- ): Boolean {
216- val url = request.url.toString().replace(" ///" , " /" ).lowercase()
217- val callbackUrl = viewModel.oAuthConfig.redirectUri.replace(" ///" , " /" ).lowercase()
218- val migrationFinished = url.startsWith(callbackUrl)
219-
220- if (migrationFinished) {
221- viewModel.authFinished.value = true
222- viewModel.loading.value = true
223-
224- val params = UriFragmentParser .parse(request.url)
225- val error = params[" error" ]
226- // Did we fail?
227- when {
228- error != null -> {
229- logMigrationError(
230- resultCallback = resultCallback,
231- error = error,
232- errorDesc = params[" error_description" ],
233- e = null ,
234- )
235- }
225+ loadUrl(frontDoorUrl)
226+ }
227+
228+ // This implementation is very similar to [LoginActivity.AuthWebViewClient] but the
229+ // code cannot be shared due to the heavy reliance on the ViewModel.
230+ @VisibleForTesting
231+ internal inner class TokenMigrationClientManager (
232+ val resultCallback : MigrationCallbackRegistry .MigrationCallbacks ,
233+ val instanceServer : String ,
234+ ) : WebViewClient() {
235+
236+ override fun shouldOverrideUrlLoading (
237+ view : WebView ,
238+ request : WebResourceRequest ,
239+ ): Boolean {
240+ val url = request.url.toString().replace(" ///" , " /" ).lowercase()
241+ val callbackUrl = viewModel.oAuthConfig.redirectUri.replace(" ///" , " /" ).lowercase()
242+ val migrationFinished = url.startsWith(callbackUrl)
243+
244+ if (migrationFinished) {
245+ viewModel.authFinished.value = true
246+ viewModel.loading.value = true
247+
248+ val params = UriFragmentParser .parse(request.url)
249+ val error = params[" error" ]
250+ // Did we fail?
251+ when {
252+ error != null -> {
253+ logMigrationError(
254+ resultCallback = resultCallback,
255+ error = error,
256+ errorDesc = params[" error_description" ],
257+ e = null ,
258+ )
259+ }
260+
261+ else -> {
262+ // Show loading while we PKCE and/or create user account.
263+ viewModel.authFinished.value = true
264+ viewModel.loading.value = true
236265
237- else -> {
238- // Show loading while we PKCE and/or create user account.
239- viewModel.authFinished.value = true
240- viewModel.loading.value = true
241-
242- CoroutineScope (Default ).launch {
243- when {
244- viewModel.useWebServerFlow ->
245- viewModel.onWebServerFlowComplete(
246- code = params[" code" ],
247- onAuthFlowError = resultCallback.onMigrationError,
248- onAuthFlowSuccess = resultCallback.onMigrationSuccess,
249- loginServer = instanceServer,
250- tokenMigration = true ,
251- ).join()
252-
253- else ->
254- viewModel.onAuthFlowComplete(
255- tr = TokenEndpointResponse (params),
256- onAuthFlowError = resultCallback.onMigrationError,
257- onAuthFlowSuccess = resultCallback.onMigrationSuccess,
258- tokenMigration = true ,
259- )
260- }
261-
262- // Wait until we are completely finished so progress indicator is shown.
263- finish()
266+ CoroutineScope (Default ).launch {
267+ when {
268+ viewModel.useWebServerFlow ->
269+ viewModel.onWebServerFlowComplete(
270+ code = params[" code" ],
271+ onAuthFlowError = resultCallback.onMigrationError,
272+ onAuthFlowSuccess = resultCallback.onMigrationSuccess,
273+ loginServer = instanceServer,
274+ tokenMigration = true ,
275+ ).join()
276+
277+ else ->
278+ viewModel.onAuthFlowComplete(
279+ tr = TokenEndpointResponse (params),
280+ onAuthFlowError = resultCallback.onMigrationError,
281+ onAuthFlowSuccess = resultCallback.onMigrationSuccess,
282+ tokenMigration = true ,
283+ )
264284 }
285+
286+ // Wait until we are completely finished so progress indicator is shown.
287+ finish()
265288 }
266289 }
267290 }
268-
269- return migrationFinished
270291 }
271292
272- override fun onPageStarted (view : WebView ? , url : String? , favicon : Bitmap ? ) {
273- super .onPageStarted(view, url, favicon)
274- viewModel.loading.value = true
275- }
293+ return migrationFinished
294+ }
295+
296+ override fun onPageStarted (view : WebView ? , url : String? , favicon : Bitmap ? ) {
297+ super .onPageStarted(view, url, favicon)
298+ viewModel.loading.value = true
299+ }
276300
277- override fun onPageFinished (view : WebView ? , url : String? ) {
278- view?.evaluateJavascript(BACKGROUND_COLOR_JAVASCRIPT ) { result ->
279- makeStatusBarVisible()
280- validateAndExtractBackgroundColor(result)?.let { color ->
281- viewModel.dynamicBackgroundColor.value = color
301+ override fun onPageFinished (view : WebView ? , url : String? ) {
302+ view?.evaluateJavascript(BACKGROUND_COLOR_JAVASCRIPT ) { result ->
303+ makeStatusBarVisible()
304+ validateAndExtractBackgroundColor(result)?.let { color ->
305+ viewModel.dynamicBackgroundColor.value = color
282306
283- // This check is inside validateAndExtractBackgroundColor because we only
284- // want to stop showing the spinner if WebView UI is actually displayed.
285- if (! viewModel.authFinished.value) {
286- viewModel.loading.value = false
287- }
307+ // This check is inside validateAndExtractBackgroundColor because we only
308+ // want to stop showing the spinner if WebView UI is actually displayed.
309+ if (! viewModel.authFinished.value) {
310+ viewModel.loading.value = false
288311 }
289312 }
290-
291- super .onPageFinished(view, url)
292313 }
293- }
294314
295- loadUrl(frontDoorUrl)
315+ super .onPageFinished(view, url)
316+ }
296317 }
297318
298319 private fun logMigrationError (
@@ -320,5 +341,8 @@ internal class TokenMigrationActivity : ComponentActivity() {
320341 const val EXTRA_CALLBACK_ID = " MIGRATION_CALLBACK"
321342
322343 const val TAG = " TokenMigrationActivity"
344+
345+ // Used for mocking the webview in tests.
346+ var webViewFactory = { context: Context -> WebView (context) }
323347 }
324348}
0 commit comments