diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index 4069244d45..e88417174d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -150,7 +150,6 @@ import org.json.JSONObject import java.lang.String.format import java.net.URI import java.net.URLDecoder -import java.net.URLEncoder import java.security.PrivateKey import java.security.cert.X509Certificate @@ -768,9 +767,17 @@ open class LoginActivity : FragmentActivity() { * Launches the current login server in a Custom Tab. This allows Admins * to authenticate with a Passkey or by other methods that require * Advanced/Browser Authentication. + * + * This function is a no-op for Welcome Discovery. */ @Deprecated(message = "This function will be replaced by a permanent solution in 14.0.") fun launchLoginForAdminsAction() { + val selectedServer = SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url?.toUri() + if (selectedServer?.let { isSalesforceWelcomeDiscoveryUrlPath(it) } == true) { + w(TAG, "launchLoginForAdminsAction is a no-op for Welcome Discovery; " + + "LFA requires an app-unique OAuth callback") + return + } val loginUrl = viewModel.browserCustomTabUrl.value ?: return loadLoginPageInCustomTab(loginUrl, adminLoginCustomTabLauncher) } @@ -1034,31 +1041,6 @@ open class LoginActivity : FragmentActivity() { return isWelcomeDiscoveryUrl || hasLoginHint } - /** - * Creates a Salesforce Welcome Discovery mobile URL using the provided - * Salesforce Welcome Discovery host and path URL. - * @param salesforceWelcomeDiscoveryHostAndPathUrl The Salesforce Welcome - * Discovery host and path URL - * @return A Salesforce Welcome Discovery mobile URL with all required - * parameters - */ - private fun generateSalesforceWelcomeDiscoveryMobileUrl( - salesforceWelcomeDiscoveryHostAndPathUrl: Uri - ) = salesforceWelcomeDiscoveryHostAndPathUrl.buildUpon() - .appendQueryParameter( - SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID, - viewModel.oAuthConfig.consumerKey, - ) - .appendQueryParameter( - SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION, - URLEncoder.encode(SalesforceSDKManager.getInstance().appVersion, "utf8") - ) - .appendQueryParameter( - SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL, - SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL - ) - .build() - /** * Switches between default or Salesforce Welcome Discovery log in as needed * using the provided pending login server URL. @@ -1084,7 +1066,7 @@ open class LoginActivity : FragmentActivity() { this, SalesforceSDKManager.getInstance().webViewLoginActivityClass ).apply { - data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri) + data = viewModel.generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri) flags = FLAG_ACTIVITY_SINGLE_TOP }) true @@ -1496,7 +1478,7 @@ open class LoginActivity : FragmentActivity() { // region Salesforce Welcome Login Private Implementation /** The default Salesforce Welcome Discovery mobile callback URL. This value is fixed until future Salesforce Welcome updates */ - private const val SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL = "sfdc://discocallback" + internal const val SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL = "sfdc://discocallback" /** The Salesforce Welcome Discovery mobile callback URL's "login hint" parameter key */ private const val SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_LOGIN_HINT = "login_hint" diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 17e0f65024..67357115ab 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -79,6 +79,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URI +import java.net.URLEncoder import kotlin.coroutines.CoroutineContext /** @@ -299,8 +300,69 @@ open class LoginViewModel( loginUrl.addSource(selectedServer, LoginUrlSource()) } + /** + * Creates a Salesforce Welcome Discovery mobile URL using the provided + * Salesforce Welcome Discovery host and path URL. + * @param salesforceWelcomeDiscoveryHostAndPathUrl The Salesforce Welcome + * Discovery host and path URL + * @return A Salesforce Welcome Discovery mobile URL with all required + * parameters + */ + internal fun generateSalesforceWelcomeDiscoveryMobileUrl( + salesforceWelcomeDiscoveryHostAndPathUrl: Uri + ) = salesforceWelcomeDiscoveryHostAndPathUrl.buildUpon() + .appendQueryParameter( + LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID, + oAuthConfig.consumerKey, + ) + .appendQueryParameter( + LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION, + URLEncoder.encode(SalesforceSDKManager.getInstance().appVersion, "utf8") + ) + .appendQueryParameter( + LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL, + LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL + ) + .build() + /** Reloads the WebView with a newly generated authorization URL. */ open fun reloadWebView() { + // If the user-selected login server (per LoginServerManager) is Salesforce Welcome + // Discovery, load the Phase 1 discovery mobile URL directly instead of regenerating a + // Phase-2-style auth URL. We intentionally do NOT key off `selectedServer.value` here: + // during Phase 2, the VM's selectedServer is the discovered My Domain, while the + // LoginServerManager retains Welcome Discovery as the user's actual server selection. + // This branch runs BEFORE the isUsingFrontDoorBridge short-circuit by design — Welcome + // Discovery and frontdoor bridge are mutually exclusive in practice, but defense-in-depth + // ordering ensures a stray frontdoor URL cannot suppress the discovery reload. + SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url + ?.toUri()?.let { selectedLoginServerUri -> + if (isSalesforceWelcomeDiscoveryUrlPath(selectedLoginServerUri)) { + // Reset the top app bar background to White synchronously so the bar flips + // immediately when the user reloads/re-selects Welcome Discovery from Phase 2. + // dynamicBackgroundColor is otherwise only updated by AuthWebViewClient. + // onPageFinished, which runs after the new page loads — the gap leaves the + // Phase-2 color visible on the bar even though the WebView URL has changed. + dynamicBackgroundColor.value = White + // Set loginUrl first so LoginUrlSource's same-host short-circuit (host of new + // selectedServer == host of current loginUrl) suppresses an auth-URL regen + // when we reset selectedServer below. + loginUrl.value = + generateSalesforceWelcomeDiscoveryMobileUrl(selectedLoginServerUri).toString() + // Reset VM-level selectedServer back to the Welcome URL so the top app bar + // title (via defaultTitleText), menu gating in Compose (LoginView reads + // viewModel.selectedServer to hide "Login for Admin" on Phase 1), and any + // other observers of selectedServer realign with Phase 1. In Phase 2 this + // value had been overwritten with the discovered My Domain by + // applySalesforceWelcomeLoginHintAndHost → applyPendingServer. + val welcomeUrl = selectedLoginServerUri.toString() + if (selectedServer.value != welcomeUrl) { + selectedServer.value = welcomeUrl + } + return + } + } + if (!isUsingFrontDoorBridge) { selectedServer.value?.let { server -> // The Web Server Flow code challenge makes the authorization url unique each time, diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt index 4cbb9c8703..7c0dcc51ad 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt @@ -90,6 +90,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.core.net.toUri import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color @@ -147,10 +148,16 @@ fun LoginView() { val viewModel: LoginViewModel = viewModel(factory = SalesforceSDKManager.getInstance().loginViewModelFactory) val frontDoorBridgeUrl = viewModel.frontDoorBridgeUrl.observeAsState() + // Observe selectedServer and loginUrl AS COMPOSE STATE so that recomposition triggers when + // either changes — and so titleText is read inside Compose's snapshot system, not via the + // non-reactive `defaultTitleText` getter chain (which reads LiveData.getValue() directly and + // is invisible to Compose). + val selectedServer = viewModel.selectedServer.observeAsState() + val loginUrl = viewModel.loginUrl.observeAsState() val titleText = if (frontDoorBridgeUrl.value != null) { viewModel.frontdoorBridgeServer ?: "" } else { - viewModel.titleText ?: viewModel.defaultTitleText + viewModel.titleText ?: if (loginUrl.value == LoginActivity.ABOUT_BLANK) "" else selectedServer.value ?: "" } val showDevSupport = with(SalesforceSDKManager.getInstance()) { return@with if (isDebugBuild && isDevSupportEnabled()) { @@ -160,6 +167,8 @@ fun LoginView() { } } + val isWelcomeDiscoveryServer = selectedServer.value + ?.let { LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(it.toUri()) } == true val topAppBar = viewModel.topAppBar ?: { DefaultTopAppBar( backgroundColor = viewModel.topBarColor ?: viewModel.dynamicBackgroundColor.value, @@ -172,7 +181,9 @@ fun LoginView() { shouldShowBackButton = viewModel.shouldShowBackButton, showDevSupport = showDevSupport, finish = { activity.handleBackBehavior() }, - onLoginForAdmins = { activity.launchLoginForAdminsAction() }, + onLoginForAdmins = if (isWelcomeDiscoveryServer) null else { + { activity.launchLoginForAdminsAction() } + }, ) } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt index e3160e48ed..c07daa142e 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt @@ -131,9 +131,11 @@ import com.salesforce.androidsdk.R.string.sf__server_url_save import com.salesforce.androidsdk.accounts.UserAccount import com.salesforce.androidsdk.accounts.UserAccountManager import com.salesforce.androidsdk.app.SalesforceSDKManager +import androidx.core.net.toUri import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.ui.CORNER_RADIUS +import com.salesforce.androidsdk.ui.LoginActivity import com.salesforce.androidsdk.ui.LoginViewModel import com.salesforce.androidsdk.ui.PADDING_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor @@ -177,6 +179,12 @@ internal fun TestablePickerBottomSheet( if (newSelectedServer != SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer) { viewModel.loading.value = true SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer = newSelectedServer + } else if (LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(newSelectedServer.url.toUri())) { + // Re-selecting Welcome Discovery while it is already the selected server should + // return the WebView to Phase 1 (welcome.salesforce.com/discovery), even though + // LoginServerManager's selection didn't change. This mirrors the reload-button + // behavior the user expects when stuck on a discovered My Domain in Phase 2. + viewModel.reloadWebView() } } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index a95c558afd..c8cf4de667 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -39,6 +39,7 @@ import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash +import com.salesforce.androidsdk.ui.LoginActivity import com.salesforce.androidsdk.ui.LoginActivity.Companion.ABOUT_BLANK import com.salesforce.androidsdk.ui.LoginViewModel import io.mockk.coEvery @@ -69,6 +70,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.net.URI +import java.net.URLEncoder private const val FAKE_SERVER_URL = "shouldMatchNothing.salesforce.com" private const val FAKE_JWT = "1234" @@ -778,6 +780,12 @@ class LoginViewModelTest { assertTrue("isUsingFrontDoorBridge should be true", viewModel.isUsingFrontDoorBridge) assertEquals("frontDoorBridgeUrl should be front door URL", frontDoorUrl, viewModel.frontDoorBridgeUrl.value) + // Precondition: invariant ("frontdoor wins over reload") only holds when the + // LoginServerManager-selected server is NOT a Welcome Discovery URL. The Welcome Discovery + // branch in reloadWebView intentionally runs before the frontdoor short-circuit. + assertFalse(LoginActivity.isSalesforceWelcomeDiscoveryUrlPath( + (SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url ?: "").toUri())) + // Call reloadWebView viewModel.reloadWebView() testDispatcher.scheduler.advanceUntilIdle() @@ -786,6 +794,107 @@ class LoginViewModelTest { assertEquals("frontDoorBridgeUrl should still be front door URL", frontDoorUrl, viewModel.frontDoorBridgeUrl.value) } + @Test + fun test_givenLoginServerManagerSelectedServerIsWelcomeDiscovery_whenReloadWebView_thenLoginUrlIsDiscoveryMobileUrl() { + val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager + loginServerManager.addCustomLoginServer("Welcome", WELCOME_LOGIN_URL) + // Simulate Phase 2: VM's selectedServer is the discovered My Domain even though + // LoginServerManager still has Welcome Discovery selected. + viewModel.selectedServer.value = "https://acme.my.salesforce.com" + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.reloadWebView() + testDispatcher.scheduler.advanceUntilIdle() + + val resultUri = viewModel.loginUrl.value?.toUri() + assertNotNull(resultUri) + assertTrue(LoginActivity.isSalesforceWelcomeDiscoveryMobileUrl(resultUri!!)) + assertEquals(viewModel.oAuthConfig.consumerKey, + resultUri.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID)) + assertFalse(viewModel.loginUrl.value!!.contains("/services/oauth2/authorize")) + } + + @Test + fun test_givenLoginServerManagerSelectedServerIsNotWelcomeDiscovery_whenReloadWebView_thenLoginUrlIsAuthUrl() { + // LoginServerManager defaults to Production (login.salesforce.com). + viewModel.selectedServer.value = "https://login.salesforce.com" + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.reloadWebView() + testDispatcher.scheduler.advanceUntilIdle() + + val url = viewModel.loginUrl.value + assertNotNull(url) + assertTrue(url!!.contains("/services/oauth2/authorize")) + assertFalse(LoginActivity.isSalesforceWelcomeDiscoveryUrlPath(url.toUri())) + } + + @Test + fun test_givenLoginServerManagerSelectedServerIsWelcomeDiscovery_whenIsUsingFrontDoorBridge_andReloadWebView_thenLoginUrlIsDiscoveryMobileUrl() { + val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager + loginServerManager.addCustomLoginServer("Welcome", WELCOME_LOGIN_URL) + val frontdoorUrl = "https://example.my.salesforce.com/secur/frontdoor.jsp?sid=fake" + viewModel.loginWithFrontDoorBridgeUrl(frontdoorUrl, pkceCodeVerifier = null) + assertTrue(viewModel.isUsingFrontDoorBridge) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.reloadWebView() + testDispatcher.scheduler.advanceUntilIdle() + + val resultUri = viewModel.loginUrl.value?.toUri() + assertNotNull(resultUri) + assertTrue(LoginActivity.isSalesforceWelcomeDiscoveryMobileUrl(resultUri!!)) + assertNotEquals(frontdoorUrl, viewModel.loginUrl.value) + } + + /** + * Regression guard for the Phase-2 reload bug: when the user is in Phase 2 of the Welcome + * Discovery flow, viewModel.selectedServer is the discovered My Domain (NOT the Welcome URL), + * but LoginServerManager retains Welcome as the user's actual server selection. Reload must + * return the WebView to Phase 1. + */ + @Test + fun test_givenInPhase2OfWelcomeDiscovery_whenReloadWebView_thenLoginUrlReturnsToPhase1() { + val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager + loginServerManager.addCustomLoginServer("Welcome", WELCOME_LOGIN_URL) + // Phase 2 state: VM mirrors the My Domain from applySalesforceWelcomeLoginHintAndHost. + val myDomainUrl = "https://acme.my.salesforce.com" + viewModel.selectedServer.value = myDomainUrl + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.reloadWebView() + testDispatcher.scheduler.advanceUntilIdle() + + val resultUri = viewModel.loginUrl.value?.toUri() + assertNotNull(resultUri) + assertEquals("welcome.salesforce.com", resultUri!!.host) + assertEquals(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_URL_PATH, resultUri.path) + assertFalse(viewModel.loginUrl.value!!.contains(myDomainUrl)) + assertFalse(viewModel.loginUrl.value!!.contains("/services/oauth2/authorize")) + + // Reload must also realign VM-level selectedServer back to the Welcome URL so the top + // app bar title (defaultTitleText reads selectedServer.value) and the Compose menu + // gating ("Login for Admin" hidden when selectedServer is the discovery URL) follow. + assertEquals(WELCOME_LOGIN_URL, viewModel.selectedServer.value) + } + + @Test + fun test_givenConsumerKeyAndAppVersion_whenGenerateSalesforceWelcomeDiscoveryMobileUrl_thenReturnsExpectedUrl() { + val input = "https://welcome.salesforce.com/discovery".toUri() + val result = viewModel.generateSalesforceWelcomeDiscoveryMobileUrl(input) + + assertEquals("welcome.salesforce.com", result.host) + assertEquals("/discovery", result.path) + assertEquals(viewModel.oAuthConfig.consumerKey, + result.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_ID)) + assertEquals(URLEncoder.encode(SalesforceSDKManager.getInstance().appVersion, "utf8"), + result.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION)) + assertEquals("sfdc://discocallback", + result.getQueryParameter(LoginActivity.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CALLBACK_URL)) + // Companion validator round-trip: + assertTrue(LoginActivity.isSalesforceWelcomeDiscoveryMobileUrl(result)) + } + @Test fun reloadWebView_WithUserAgentFlow_SetsAboutBlankFirst() { try { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt index 15a7a7d301..c3726d5b53 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt @@ -352,6 +352,26 @@ class LoginViewActivityTest { loginForAdminsButton.assertDoesNotExist() } + @Test + fun test_givenOnLoginForAdminsIsNull_whenDropdownMenuOpened_thenLoginForAdminsItemIsAbsent() { + androidComposeTestRule.setContent { + DefaultTopAppBarTestWrapper( + onLoginForAdmins = null, + ) + } + + val menu = androidComposeTestRule.onNodeWithContentDescription( + androidComposeTestRule.activity.getString(R.string.sf__more_options) + ) + val loginForAdminsButton = androidComposeTestRule.onNodeWithText( + androidComposeTestRule.activity.getString(R.string.sf__login_for_admins) + ) + + menu.assertIsDisplayed() + menu.performClick() + loginForAdminsButton.assertDoesNotExist() + } + @Test fun bottomAppBar_WithNoButton_DisplaysCorrectly() { androidComposeTestRule.setContent { @@ -540,7 +560,7 @@ class LoginViewActivityTest { shouldShowBackButton: Boolean = false, showDevSupport: (() -> Unit)? = { }, finish: () -> Unit = { }, - onLoginForAdmins: (() -> Unit) = { }, + onLoginForAdmins: (() -> Unit)? = { }, ) { DefaultTopAppBar( backgroundColor, titleText, titleTextColor, showServerPicker, clearCookies,