Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
244 changes: 148 additions & 96 deletions libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager.ERROR_CODE_CANCELED
import android.accounts.AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE
import android.annotation.SuppressLint
import android.app.Activity
import android.app.admin.DevicePolicyManager.ACTION_SET_NEW_PASSWORD
import android.content.Context
import android.content.Intent
Expand Down Expand Up @@ -74,8 +73,9 @@ import androidx.activity.addCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PROTECTED
Expand Down Expand Up @@ -105,7 +105,7 @@ import androidx.core.content.ContextCompat.getMainExecutor
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Observer
import com.salesforce.androidsdk.R.color.sf__background
import com.salesforce.androidsdk.R.color.sf__background_dark
import com.salesforce.androidsdk.R.color.sf__primary_color
Expand Down Expand Up @@ -147,7 +147,6 @@ import com.salesforce.androidsdk.util.SalesforceSDKLogger.e
import com.salesforce.androidsdk.util.SalesforceSDKLogger.w
import com.salesforce.androidsdk.util.UriFragmentParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Default
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -175,6 +174,10 @@ import java.security.cert.X509Certificate
* them.
*/
open class LoginActivity : FragmentActivity() {

/** The activity result launcher used when browser-based authentication loads the OAuth authorization URL in the external browser custom tab activity */
private val customTabLauncher = registerForActivityResult(StartActivityForResult(), CustomTabActivityResult())

// View Model
@VisibleForTesting(otherwise = PROTECTED)
open val viewModel: LoginViewModel
Expand All @@ -183,6 +186,7 @@ open class LoginActivity : FragmentActivity() {
// Webview and Clients
@VisibleForTesting(otherwise = PROTECTED)
open val webViewClient = AuthWebViewClient()

@VisibleForTesting(otherwise = PROTECTED)
open val webChromeClient = WebChromeClient()
open val webView: WebView by lazy {
Expand Down Expand Up @@ -226,7 +230,7 @@ open class LoginActivity : FragmentActivity() {
SalesforceSDKManager.getInstance().setViewNavigationVisibility(this)
}

applyIntent(intent)
applyIntent()

// Don't let sharedBrowserSession org setting stop a new user from logging in.
if (intent.extras?.getBoolean(NEW_USER) == true) {
Expand Down Expand Up @@ -272,62 +276,9 @@ open class LoginActivity : FragmentActivity() {
onBackPressedDispatcher.addCallback { handleBackBehavior() }
}

val customTabLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
// Check if the user backed out of the custom tab.
if (result.resultCode == Activity.RESULT_CANCELED) {
if (viewModel.singleServerCustomTabActivity) {
// Show blank page and spinner until PKCE is done.
viewModel.loginUrl.value = ABOUT_BLANK
} else {
// Don't show server picker if we are re-authenticating with cookie.
clearWebView(showServerPicker = !sharedBrowserSession)
}
}
}

// Take action on selected server change.
viewModel.selectedServer.observe(this) { selectedServer ->

// Guard against observing a selected server already provided by the intent data, such as a Salesforce Welcome Discovery mobile URL.
val selectedServerUri = selectedServer.toUri()
if (intent.data?.host == selectedServerUri.host) {
return@observe
}

// Use the URL to switch between default or Salesforce Welcome Discovery log in, if applicable.
if (switchDefaultOrSalesforceWelcomeDiscoveryLogin(selectedServerUri)) {
return@observe
}

if (viewModel.singleServerCustomTabActivity) {
// Skip fetching authorization and show custom tab immediately.
viewModel.reloadWebView()
viewModel.loginUrl.value?.let { url ->
loadLoginPageInCustomTab(url, customTabLauncher)
}
} else {
with(SalesforceSDKManager.getInstance()) {
// Fetch well known config and load in custom tab if required.
fetchAuthenticationConfiguration {
/* Browser-based authentication is applicable when not authenticating with a front-door bridge URL */
if (isBrowserLoginEnabled && !viewModel.isUsingFrontDoorBridge) {
if (useWebServerAuthentication) {
viewModel.loginUrl.value?.let { url -> loadLoginPageInCustomTab(url, customTabLauncher) }
} else {
/* Reload the webview now that isBrowserLoginEnabled has been set
to true so that we generate an authorization URL with PKCE values. */
lifecycleScope.launch(Dispatchers.Main) {
viewModel.reloadWebView()
viewModel.loginUrl.value?.let { url -> loadLoginPageInCustomTab(url, customTabLauncher) }
}
}
}
}
}
}
}
// Add view model observers.
viewModel.browserCustomTabUrl.observe(this, BrowserCustomTabUrlObserver())
viewModel.pendingServer.observe(this, PendingServerObserver())

// Support magic links
if (viewModel.jwt != null) {
Expand Down Expand Up @@ -359,7 +310,10 @@ open class LoginActivity : FragmentActivity() {
return
}

applyIntent(intent)
// Store the new intent and apply it to the activity.
setIntent(intent)
applyIntent()
viewModel.applyPendingServer(pendingLoginServer = viewModel.pendingServer.value)
}

private fun clearWebView(showServerPicker: Boolean = true) {
Expand Down Expand Up @@ -984,52 +938,38 @@ open class LoginActivity : FragmentActivity() {
)
.build()

/**
* Determines if the provided proposed selected server URL is a switch from
* Salesforce Welcome Discovery back to default log in.
* @param proposedSelectedServerUrl The proposed selected server URL
* @return Boolean true if the provided proposed selected server URL is a
* switch from Salesforce Welcome Discovery back to the default log in,
* false otherwise.
* */
private fun isSwitchFromSalesforceWelcomeDiscoveryToDefaultLogin(
proposedSelectedServerUrl: Uri
) = viewModel.loginUrl.value?.toUri()?.let { loginUrl ->
isSalesforceWelcomeDiscoveryMobileUrl(this, loginUrl) && !(isSalesforceWelcomeDiscoveryMobileUrl(this, proposedSelectedServerUrl))
} ?: false

/**
* Switches between default or Salesforce Welcome Discovery log in as needed
* using the provided proposed selected server URL.
* @param uri The proposed selected server URL
* using the provided pending login server URL.
* @param pendingLoginServerUri The pending login server URL
* @return Boolean true if a switch between default or Salesforce Welcome
* Discovery log is made, false otherwise.
*/
private fun switchDefaultOrSalesforceWelcomeDiscoveryLogin(uri: Uri) =
private fun switchDefaultOrSalesforceWelcomeDiscoveryLogin(pendingLoginServerUri: Uri) =

// If the selected server has changed to a new Salesforce Welcome Discovery URL and host.
if (isSalesforceWelcomeDiscoveryUrlPath(uri)) {
// If the pending login server is a change to a new Salesforce Welcome Discovery URL and host.
if (isSalesforceWelcomeDiscoveryUrlPath(pendingLoginServerUri)) {

// Navigate to Salesforce Welcome Discovery.
startActivity(
Intent(
this,
LoginActivity::class.java
SalesforceSDKManager.getInstance().webViewLoginActivityClass
).apply {
data = generateSalesforceWelcomeDiscoveryMobileUrl(uri)
data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri)
flags = FLAG_ACTIVITY_SINGLE_TOP
})
true
}

// If the new selected server isn't a Salesforce Welcome Discovery URL but the previous was...
else if (isSwitchFromSalesforceWelcomeDiscoveryToDefaultLogin(uri)) {
// If the pending login server isn't a Salesforce Welcome Discovery URL but the previous was...
else if (viewModel.isSwitchFromSalesforceWelcomeDiscoveryToDefaultLogin(pendingLoginServerUri)) {

// Navigate to login.
// Navigate to default login.
startActivity(
Intent(
this,
LoginActivity::class.java
SalesforceSDKManager.getInstance().webViewLoginActivityClass
).apply {
flags = FLAG_ACTIVITY_SINGLE_TOP
})
Expand Down Expand Up @@ -1075,11 +1015,10 @@ open class LoginActivity : FragmentActivity() {
// endregion

/**
* Applies a new intent to the activity, for instance when the activity is
* created or receives a new intent.
* @param intent The new intent
* (Re-)applies the intent to the activity, for instance when the activity
* is created or receives a new intent.
*/
private fun applyIntent(intent: Intent) {
private fun applyIntent() {

// If the intent is for Salesforce Welcome Discovery, apply it to the activity.
applySalesforceWelcomeDiscoveryIntent(intent)
Expand All @@ -1088,6 +1027,34 @@ open class LoginActivity : FragmentActivity() {
applyUiBridgeApiFrontDoorUrl(intent)
}

/**
* Starts a browser custom tab for the OAuth authorization URL according to
* the authentication configuration. The activity only takes action when
* browser-based authentication requires a browser custom tab to be started.
* UI front-door bridge use bypasses the need for browser custom tab.
* @param authorizationUrl The selected login server's OAuth authorization
* URL
* @param activityResultLauncher The activity result launcher to use when
* browser-based authentication requires a browser custom tab
* @param isBrowserLoginEnabled Indicates if browser-based authentication is
* enabled
* @param isUsingFrontDoorBridge Indicates if a UI bridge API front door
* bridge URL is in use
* @param singleServerCustomTabActivity Indicates single server custom
* browser tab authentication is active
*/
private fun startBrowserCustomTabAuthorization(
authorizationUrl: String,
activityResultLauncher: ActivityResultLauncher<Intent>,
isBrowserLoginEnabled: Boolean = SalesforceSDKManager.getInstance().isBrowserLoginEnabled,
isUsingFrontDoorBridge: Boolean = viewModel.isUsingFrontDoorBridge,
singleServerCustomTabActivity: Boolean = viewModel.singleServerCustomTabActivity,
) {
if ((singleServerCustomTabActivity.or(isBrowserLoginEnabled)).and(!isUsingFrontDoorBridge)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it matters for this context, but it should be noted that or and and differ from || and && in that they do not short-circuit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true. Kotlin's methods are beneficial here since they increase code coverage. The short-circuit operators produce untestable logic branches which are revealed by CodeCov. That seems well documented online. In many cases I was able to retain the operators and still hit 100% coverage, but this one stumped even the AI tools and they recommended the methods - which worked.

We wouldn't want to use Kotlin's methods if the expressions were costly or had side-effects, obviously. In this case, they're simple booleans.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, interesting! Great use case, especially for non-nullable values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks ☺️

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We wouldn't want to use Kotlin's methods if the expressions were costly or had side-effects, obviously. In this case, they're simple booleans.

Maybe there are cases where you specifically would want to ensure a side-effect happens? Like:

if (override.and(atomicInt.addAndGet(1) > MAX))

and guarantees the add happens no matter the value of override. Seems pretty niche though.

loadLoginPageInCustomTab(authorizationUrl, activityResultLauncher)
}
}

/**
* A web view client which intercepts the redirect to the OAuth callback URL. That redirect marks the end of
* the user facing portion of the authentication flow.
Expand Down Expand Up @@ -1478,13 +1445,15 @@ open class LoginActivity : FragmentActivity() {
): Boolean {
if (!uri.isHierarchical) return false
val clientIdParameter = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID)
return isSalesforceWelcomeDiscoveryUrlPath(uri) && uri.queryParameterNames?.contains(
val isDiscovery = isSalesforceWelcomeDiscoveryUrlPath(uri)

return isDiscovery && uri.queryParameterNames.contains(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID
) != null && uri.queryParameterNames?.contains(
) && uri.queryParameterNames.contains(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION
) != null && uri.queryParameterNames?.contains(
) && uri.queryParameterNames.contains(
SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL
) != null && clientIdParameter == getBootConfig(context).remoteAccessConsumerKey &&
) && clientIdParameter == getBootConfig(context).remoteAccessConsumerKey &&
(clientIdParameter == "SfdcMobileChatterAndroid" || clientIdParameter == "SfdcMobileChatteriOS") // TODO: Keep this list of client ids up to date with those supported by Salesforce Welcome Discovery or remove it when no longer required.
}

Expand All @@ -1511,7 +1480,7 @@ open class LoginActivity : FragmentActivity() {
loginHint: String,
loginHost: String,
) {
Intent(context, LoginActivity::class.java).apply {
Intent(context, SalesforceSDKManager.getInstance().webViewLoginActivityClass).apply {
putExtra(EXTRA_KEY_LOGIN_HINT, loginHint)
putExtra(EXTRA_KEY_LOGIN_HOST, loginHost)
flags = FLAG_ACTIVITY_SINGLE_TOP
Expand All @@ -1521,4 +1490,87 @@ open class LoginActivity : FragmentActivity() {

// endregion
}

// region Activity Result Callback Classes

/**
* An activity result callback used when browser-based authentication loads
* the OAuth authorization URL in the external browser custom tab activity.
* @param activity The login activity. This parameter is intended for
* testing purposes only. Defaults to this inner class receiver
*/
private inner class CustomTabActivityResult(
private val activity: LoginActivity = this@LoginActivity
) : ActivityResultCallback<ActivityResult> {

override fun onActivityResult(result: ActivityResult) {
// Check if the user backed out of the custom tab.
if (result.resultCode == RESULT_CANCELED) {
if (activity.viewModel.singleServerCustomTabActivity) {
// Show blank page and spinner until PKCE is done.
activity.viewModel.loginUrl.value = ABOUT_BLANK
} else {
// Don't show server picker if we are re-authenticating with cookie.
activity.clearWebView(showServerPicker = !activity.sharedBrowserSession)
}
}
}
}

// endregion
// region Observer Classes

/**
* An observer for browser custom tab URL that continues the authentication
* flow by loading the login URL in a web browser custom tab when browser-
* based authentication is required.
* @param activity The login activity. This parameter is intended for
* testing purposes only. Defaults to this inner class receiver
*/
internal inner class BrowserCustomTabUrlObserver(
private val activity: LoginActivity = this@LoginActivity
) : Observer<String> {
override fun onChanged(value: String) {
if (value == "about:blank") {
return
}

activity.startBrowserCustomTabAuthorization(
authorizationUrl = value,
activityResultLauncher = activity.customTabLauncher,
isBrowserLoginEnabled = SalesforceSDKManager.getInstance().isBrowserLoginEnabled,
)
}
}

/**
* An observer for pending login server that continues the authentication
* flow by determining the switch between default login and Salesforce
* Welcome Discovery before applying the pending login server to the
* activity.
* @param activity The login activity. This parameter is intended for
* testing purposes only. Defaults to this inner class receiver
*/
private inner class PendingServerObserver(
private val activity: LoginActivity = this@LoginActivity
) : Observer<String> {
override fun onChanged(value: String) {
// Guard against observing a pending login server already provided by the intent data, such as a Salesforce Welcome Discovery mobile URL.
val pendingServerUri = value.toUri()
if (activity.intent.data?.host == pendingServerUri.host || activity.intent.getStringExtra(EXTRA_KEY_LOGIN_HOST) == pendingServerUri.host) {
activity.viewModel.previousPendingServer = value
return
}

// Use the URL to switch between default or Salesforce Welcome Discovery log in, if applicable.
if (activity.switchDefaultOrSalesforceWelcomeDiscoveryLogin(pendingServerUri)) {
activity.viewModel.previousPendingServer = value
return
}

activity.viewModel.applyPendingServer(pendingLoginServer = value)
}
}

// endregion
}
Loading
Loading