diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml
index 37a7dc4f5a..1c6ad75ff3 100644
--- a/libs/SalesforceSDK/res/values/sf__strings.xml
+++ b/libs/SalesforceSDK/res/values/sf__strings.xml
@@ -127,4 +127,16 @@
Redirect URI Field
Scopes Field
Save Button
+ Save and Login
+
+
+ Simulate Discovery Result
+ Toggle Simulate Discovery Result
+ My Domain Login Host
+ User Name
+ Save Simulated Result
+ Discovery Login Host Field
+ Discovery Username Field
+ Save Simulated Result Button
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt
index 300edda251..4879c60103 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt
@@ -475,6 +475,37 @@ open class SalesforceSDKManager protected constructor(
// Used to ensure the webview is reloaded when Dev Menu Login Options are changed.
internal var loginDevMenuReload = false
+ /**
+ * When set, the next launch of [com.salesforce.androidsdk.ui.LoginActivity] against
+ * [com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL] is short-circuited
+ * to use these values instead of running the real Welcome Discovery WebView flow. This
+ * mirrors the iOS `simulatedDomainDiscoveryResult` hook and is the seam used by automated
+ * UI tests to inject a login hint and My Domain.
+ *
+ * The setter is a no-op in release builds (only honored when [isDebugBuild] is true) so
+ * release apps cannot be coerced into bypassing the real discovery flow.
+ */
+ internal var simulatedDiscoveryResult: LoginActivity.Companion.SimulatedDiscoveryResult? = null
+ set(value) {
+ if (isDebugBuild) field = value
+ }
+
+ /**
+ * When true (and [isDebugBuild] is also true), debug-only UI test affordances such as the
+ * Welcome Discovery simulation editor in
+ * [com.salesforce.androidsdk.ui.LoginOptionsActivity] are visible. Mirrors iOS' check for
+ * the `IS_UI_TESTING` launch argument in `LoginOptionsViewController.swift`.
+ *
+ * Set by a sample app's launcher Activity from an Intent extra when the activity is
+ * launched by the UI test runner. The setter is a no-op in release builds so manual
+ * launches and release-build apps never expose the affordances.
+ */
+ @VisibleForTesting
+ var isUiTesting: Boolean = false
+ set(value) {
+ if (isDebugBuild) field = value
+ }
+
/**
* The regular expression pattern used to detect "Use Custom Domain" input
* from login web view.
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
index 882f4e6c5b..65a8b0842d 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
@@ -974,6 +974,44 @@ open class LoginActivity : FragmentActivity() {
}
}
+ /**
+ * If [SalesforceSDKManager.simulatedDiscoveryResult] has been armed by the Login Options
+ * Discovery Result Editor, consume it and dispatch the same hint+host intent the real
+ * `sfdc://discocallback` callback URL handler would dispatch. This is the test seam
+ * mirroring iOS' `simulatedDomainDiscoveryResult` hook.
+ *
+ * The WebView's current URL is set to ABOUT_BLANK before dispatching so that the
+ * subsequent OAuth-URL reload triggered by [applySalesforceWelcomeLoginHintAndHost]'s
+ * `addCustomLoginServer` is not suppressed by [LoginViewModel.LoginUrlSource]'s
+ * same-host short-circuit when the simulated host happens to equal the previously
+ * selected server.
+ *
+ * @return Boolean true when the simulated result was applied and the welcome.salesforce.com
+ * discovery WebView load should be skipped, false otherwise.
+ */
+ @VisibleForTesting
+ internal fun applySimulatedDiscoveryResultIfApplicable(): Boolean {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ // Defense-in-depth: refuse to consume a simulated result outside debug builds, mirroring
+ // iOS' `#if DEBUG` wrap on the same hook.
+ if (!sdkManager.isDebugBuild) return false
+ val simulated = sdkManager.simulatedDiscoveryResult ?: return false
+
+ // Consume the simulated result so subsequent welcome-discovery selections run the real flow
+ // unless re-armed via Login Options.
+ sdkManager.simulatedDiscoveryResult = null
+
+ // Reset the WebView so the post-intent reload isn't dropped by the same-host short-circuit.
+ viewModel.loginUrl.value = ABOUT_BLANK
+
+ startDefaultLoginWithHintAndHost(
+ context = this,
+ loginHint = simulated.loginHint,
+ loginHost = simulated.loginHost,
+ )
+ return true
+ }
+
/**
* If the intent has the Salesforce Welcome login hint and host, applies
* those for use in the generation of the OAuth URL. This is used by
@@ -1044,16 +1082,23 @@ open class LoginActivity : FragmentActivity() {
// 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,
- SalesforceSDKManager.getInstance().webViewLoginActivityClass
- ).apply {
- data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri)
- flags = FLAG_ACTIVITY_SINGLE_TOP
- })
- true
+ // If a simulated discovery result has been armed via Login Options, bypass the real
+ // welcome.salesforce.com discovery WebView and route directly to default login with
+ // the injected hint and host. Mirrors iOS' simulatedDomainDiscoveryResult hook.
+ if (applySimulatedDiscoveryResultIfApplicable()) {
+ true
+ } else {
+ // Navigate to Salesforce Welcome Discovery.
+ startActivity(
+ Intent(
+ this,
+ SalesforceSDKManager.getInstance().webViewLoginActivityClass
+ ).apply {
+ data = generateSalesforceWelcomeDiscoveryMobileUrl(pendingLoginServerUri)
+ flags = FLAG_ACTIVITY_SINGLE_TOP
+ })
+ true
+ }
}
// If the pending login server isn't a Salesforce Welcome Discovery URL but the previous was...
@@ -1485,6 +1530,17 @@ open class LoginActivity : FragmentActivity() {
@VisibleForTesting
const val SALESFORCE_WELCOME_DISCOVERY_URL_PATH = "/discovery"
+ /**
+ * The result of a simulated Welcome Discovery flow. Holds the values that the real
+ * `sfdc://discocallback?login_hint=&my_domain=` URL would have provided. Set on
+ * [SalesforceSDKManager.simulatedDiscoveryResult] from the Login Options screen to
+ * bypass the email round-trip during automated tests.
+ */
+ data class SimulatedDiscoveryResult(
+ val loginHint: String,
+ val loginHost: String,
+ )
+
/**
* Determines if the provided URL has the Salesforce Welcome Discovery
* path.
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt
index 7d4d1e23f0..5028544dfa 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt
@@ -66,6 +66,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.annotation.VisibleForTesting
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -82,10 +83,11 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.salesforce.androidsdk.R
-import com.salesforce.androidsdk.R.string.sf__server_url_save
+import com.salesforce.androidsdk.R.string.sf__login_options_save_and_login
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.config.BootConfig
import com.salesforce.androidsdk.config.OAuthConfig
+import com.salesforce.androidsdk.ui.LoginActivity.Companion.SimulatedDiscoveryResult
import com.salesforce.androidsdk.ui.components.TEXT_SIZE
import com.salesforce.androidsdk.ui.theme.hintTextColor
import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
@@ -274,7 +276,7 @@ fun BootConfigView(config: OAuthConfig? = null) {
},
) {
Text(
- text = stringResource(sf__server_url_save),
+ text = stringResource(sf__login_options_save_and_login),
fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium,
color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer,
)
@@ -379,9 +381,133 @@ fun LoginOptionsScreen(
if (useDynamicConfig) {
BootConfigView(overrideConfig)
}
+
+ // Welcome Discovery simulation editor: visible only when the launcher Activity flagged
+ // the process as UI-testing (and only in debug builds, enforced by the setter).
+ // Mirrors iOS' IS_UI_TESTING gate in LoginOptionsViewController.swift.
+ val sdkManager = SalesforceSDKManager.getInstance()
+ if (sdkManager.isDebugBuild && sdkManager.isUiTesting) {
+ HorizontalDivider()
+
+ var simulateDiscovery by remember {
+ mutableStateOf(sdkManager.simulatedDiscoveryResult != null)
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(PADDING_SIZE.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.sf__login_options_discovery_simulate_title),
+ modifier = Modifier.height(50.dp).wrapContentHeight(align = Alignment.CenterVertically),
+ )
+ val toggleDesc = stringResource(R.string.sf__login_options_discovery_toggle_content_description)
+ Switch(
+ checked = simulateDiscovery,
+ onCheckedChange = {
+ simulateDiscovery = it
+ if (!simulateDiscovery) {
+ sdkManager.simulatedDiscoveryResult = null
+ }
+ },
+ modifier = Modifier.clearAndSetSemantics {
+ contentDescription = toggleDesc
+ toggleableState = ToggleableState(simulateDiscovery)
+ role = Role.Switch
+ }
+ )
+ }
+
+ if (simulateDiscovery) {
+ DiscoveryResultEditor()
+ }
+ }
}
}
+/**
+ * Editor body for simulating a Welcome Discovery result (login host + username). Mirrors
+ * iOS `DiscoveryResultEditor.swift`. Tapping Save arms
+ * [SalesforceSDKManager.simulatedDiscoveryResult]; the next Welcome Discovery launch will
+ * inject these values instead of running the real WebView discovery flow. Hosted under a
+ * Switch in [LoginOptionsScreen] (parallels the Override Boot Config section).
+ */
+@Composable
+fun DiscoveryResultEditor(
+ sdkManager: SalesforceSDKManager = SalesforceSDKManager.getInstance(),
+) {
+ val initial = sdkManager.simulatedDiscoveryResult
+ var loginHost by remember { mutableStateOf(initial?.loginHost ?: "") }
+ var username by remember { mutableStateOf(initial?.loginHint ?: "") }
+
+ val hostLabel = stringResource(R.string.sf__login_options_discovery_login_host_label)
+ val userLabel = stringResource(R.string.sf__login_options_discovery_username_label)
+ val saveText = stringResource(R.string.sf__login_options_discovery_save_button)
+ val hostFieldDesc = stringResource(R.string.sf__login_options_discovery_login_host_field_content_description)
+ val userFieldDesc = stringResource(R.string.sf__login_options_discovery_username_field_content_description)
+ val saveButtonDesc = stringResource(R.string.sf__login_options_discovery_save_button_content_description)
+
+ Column(modifier = Modifier.padding(PADDING_SIZE.dp)) {
+ OutlinedTextField(
+ value = loginHost,
+ onValueChange = { loginHost = it },
+ label = { Text(hostLabel) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = hostFieldDesc },
+ )
+
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text(userLabel) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = PADDING_SIZE.dp)
+ .semantics { contentDescription = userFieldDesc },
+ )
+
+ Button(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = PADDING_SIZE.dp)
+ .semantics { contentDescription = saveButtonDesc },
+ shape = RoundedCornerShape(CORNER_RADIUS.dp),
+ contentPadding = PaddingValues(PADDING_SIZE.dp),
+ colors = ButtonColors(
+ containerColor = colorScheme.tertiary,
+ contentColor = colorScheme.tertiary,
+ disabledContainerColor = colorScheme.surfaceVariant,
+ disabledContentColor = colorScheme.surfaceVariant,
+ ),
+ onClick = {
+ sdkManager.simulatedDiscoveryResult = applySimulatedDiscoveryResult(
+ loginHost = loginHost,
+ username = username,
+ )
+ },
+ ) {
+ Text(text = saveText, color = colorScheme.onPrimary)
+ }
+ }
+}
+
+/**
+ * Builds a [SimulatedDiscoveryResult] from the current editor values, or null when the host
+ * is empty. Mirrors iOS `applySimulatedResult` (empty host returns nil). Visible for testing.
+ */
+@VisibleForTesting
+internal fun applySimulatedDiscoveryResult(
+ loginHost: String,
+ username: String,
+): SimulatedDiscoveryResult? {
+ val trimmedHost = loginHost.trim()
+ val trimmedUser = username.trim()
+ return if (trimmedHost.isEmpty()) null
+ else SimulatedDiscoveryResult(loginHint = trimmedUser, loginHost = trimmedHost)
+}
+
@ExcludeFromJacocoGeneratedReport
@Preview(showBackground = true)
@Composable
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 eb024cf7a5..e093d9872a 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
@@ -43,7 +43,10 @@ import com.salesforce.androidsdk.ui.LoginActivity.Companion.SALESFORCE_WELCOME_D
import com.salesforce.androidsdk.ui.LoginActivity.Companion.SALESFORCE_WELCOME_DISCOVERY_MOBILE_URL_QUERY_PARAMETER_KEY_CLIENT_VERSION
import com.salesforce.androidsdk.ui.LoginActivity.Companion.SALESFORCE_WELCOME_DISCOVERY_URL_PATH
import com.salesforce.androidsdk.ui.LoginActivity.Companion.isSalesforceWelcomeDiscoveryMobileUrl
+import com.salesforce.androidsdk.ui.LoginActivity.Companion.SimulatedDiscoveryResult
import com.salesforce.androidsdk.ui.LoginActivity.Companion.startDefaultLoginWithHintAndHost
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.config.LoginServerManager
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@@ -325,5 +328,70 @@ class LoginActivityTest {
}
}
+ @Test
+ fun applySimulatedDiscoveryResult_noArmedResult_returnsFalse() {
+ SalesforceSDKManager.getInstance().simulatedDiscoveryResult = null
+ val activity = mockk(relaxed = true)
+ every { activity.applySimulatedDiscoveryResultIfApplicable() } answers { callOriginal() }
+
+ assertFalse(activity.applySimulatedDiscoveryResultIfApplicable())
+ }
+
+ @Test
+ fun applySimulatedDiscoveryResult_armedResult_consumesAndReturnsTrue() {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ sdkManager.simulatedDiscoveryResult = SimulatedDiscoveryResult(
+ loginHint = "user@example.com",
+ loginHost = "test.my.example.com",
+ )
+ try {
+ val loginUrl = mockk>(relaxed = true)
+ val viewModel = mockk(relaxed = true)
+ every { viewModel.loginUrl } returns loginUrl
+ val activity = mockk(relaxed = true)
+ every { activity.viewModel } returns viewModel
+ every { activity.applySimulatedDiscoveryResultIfApplicable() } answers { callOriginal() }
+
+ assertTrue(activity.applySimulatedDiscoveryResultIfApplicable())
+ // Armed result is consumed (cleared) on apply.
+ org.junit.Assert.assertNull(sdkManager.simulatedDiscoveryResult)
+ // WebView is reset so the next reload isn't suppressed by same-host short-circuit.
+ verify(exactly = 1) { loginUrl.value = ABOUT_BLANK }
+ } finally {
+ sdkManager.simulatedDiscoveryResult = null
+ }
+ }
+
+ @Test
+ fun switchDefaultOrSalesforceWelcomeDiscoveryLogin_consumesArmedSimulationInsteadOfLoadingDiscoveryWebView() {
+ val sdkManager = SalesforceSDKManager.getInstance()
+ sdkManager.simulatedDiscoveryResult = SimulatedDiscoveryResult(
+ loginHint = "user@example.com",
+ loginHost = "test.my.example.com",
+ )
+ try {
+ val loginUrl = mockk>(relaxed = true)
+ val viewModel = mockk(relaxed = true)
+ every { viewModel.loginUrl } returns loginUrl
+ val activity = mockk(relaxed = true)
+ every { activity.viewModel } returns viewModel
+ every { activity.applySimulatedDiscoveryResultIfApplicable() } answers { callOriginal() }
+ every { activity.switchDefaultOrSalesforceWelcomeDiscoveryLogin(any()) } answers { callOriginal() }
+
+ assertTrue(
+ activity.switchDefaultOrSalesforceWelcomeDiscoveryLogin(
+ "https://welcome.example.com/discovery".toUri()
+ )
+ )
+ // Discovery WebView intent should NOT be dispatched - the simulation hook handled it.
+ verify(exactly = 0) {
+ activity.startActivity(match { it.data?.path?.contains("/discovery") == true })
+ }
+ org.junit.Assert.assertNull(sdkManager.simulatedDiscoveryResult)
+ } finally {
+ sdkManager.simulatedDiscoveryResult = null
+ }
+ }
+
// endregion
}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt
index b960dfc249..ca5e967340 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt
@@ -111,7 +111,7 @@ class LoginOptionsActivityTest {
composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description),
)
saveButton = composeTestRule.onNodeWithText(
- composeTestRule.activity.getString(R.string.sf__server_url_save),
+ composeTestRule.activity.getString(R.string.sf__login_options_save_and_login),
)
}
@@ -410,4 +410,89 @@ class LoginOptionsActivityTest {
composeTestRule.activity.finish()
assertTrue(SalesforceSDKManager.getInstance().loginDevMenuReload)
}
+
+ // region Welcome Discovery — DiscoveryResultEditor
+
+ @Test
+ fun applySimulatedDiscoveryResult_emptyHost_returnsNull() {
+ assertNull(applySimulatedDiscoveryResult(loginHost = "", username = "user@example.com"))
+ assertNull(applySimulatedDiscoveryResult(loginHost = " ", username = "user@example.com"))
+ }
+
+ @Test
+ fun applySimulatedDiscoveryResult_validHost_returnsTrimmedResult() {
+ val result = applySimulatedDiscoveryResult(
+ loginHost = " test.my.salesforce.com ",
+ username = " user@example.com ",
+ )
+ assertNotNull(result)
+ assertEquals("test.my.salesforce.com", result?.loginHost)
+ assertEquals("user@example.com", result?.loginHint)
+ }
+
+ @Test
+ fun applySimulatedDiscoveryResult_validHostEmptyUser_keepsEmptyUser() {
+ // iOS allows empty user (host-only), so do we.
+ val result = applySimulatedDiscoveryResult(
+ loginHost = "test.my.salesforce.com",
+ username = "",
+ )
+ assertNotNull(result)
+ assertEquals("test.my.salesforce.com", result?.loginHost)
+ assertEquals("", result?.loginHint)
+ }
+
+ @Test
+ fun discoveryResultEditor_saveButton_armsSdkManagerSimulatedResult() {
+ // The editor is gated on isUiTesting; flip it on for this test (debug build)
+ // and recompose so the gated UI is rendered.
+ SalesforceSDKManager.getInstance().isUiTesting = true
+ composeTestRule.activity.runOnUiThread { composeTestRule.activity.recreate() }
+ composeTestRule.waitForIdle()
+
+ val toggle = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(
+ R.string.sf__login_options_discovery_toggle_content_description
+ ),
+ )
+ val saveButton = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(
+ R.string.sf__login_options_discovery_save_button_content_description
+ ),
+ )
+ val hostField = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(
+ R.string.sf__login_options_discovery_login_host_field_content_description
+ ),
+ )
+ val userField = composeTestRule.onNodeWithContentDescription(
+ composeTestRule.activity.getString(
+ R.string.sf__login_options_discovery_username_field_content_description
+ ),
+ )
+
+ assertNull(SalesforceSDKManager.getInstance().simulatedDiscoveryResult)
+
+ toggle.performScrollTo()
+ toggle.performClick()
+ composeTestRule.waitForIdle()
+
+ hostField.performScrollTo()
+ hostField.performTextInput("test.my.salesforce.com")
+ userField.performTextInput("user@example.com")
+ saveButton.performScrollTo()
+ saveButton.performClick()
+ composeTestRule.waitForIdle()
+
+ val armed = SalesforceSDKManager.getInstance().simulatedDiscoveryResult
+ assertNotNull(armed)
+ assertEquals("test.my.salesforce.com", armed?.loginHost)
+ assertEquals("user@example.com", armed?.loginHint)
+
+ // Cleanup: clear simulation + UI testing flag so they don't leak.
+ SalesforceSDKManager.getInstance().simulatedDiscoveryResult = null
+ SalesforceSDKManager.getInstance().isUiTesting = false
+ }
+
+ // endregion
}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt
index 1eb8f62c95..40ed85c914 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt
@@ -204,14 +204,16 @@ class RefreshTokenMigrationTests: AuthFlowTest() {
useHybridAuthToken: Boolean,
knownLoginHostConfig: KnownLoginHostConfig,
knownUserConfig: KnownUserConfig,
+ useWelcomeDiscovery: Boolean,
) {
super.loginAndValidate(
- knownAppConfig,
- scopeSelection,
- useWebServerFlow,
+ knownAppConfig = knownAppConfig,
+ scopeSelection = scopeSelection,
+ useWebServerFlow = useWebServerFlow,
useHybridAuthToken = false,
- knownLoginHostConfig,
+ knownLoginHostConfig = knownLoginHostConfig,
knownUserConfig = user,
+ useWelcomeDiscovery = useWelcomeDiscovery,
)
}
}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/WelcomeLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/WelcomeLoginTests.kt
new file mode 100644
index 0000000000..0f19414b85
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/WelcomeLoginTests.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026-present, salesforce.com, inc.
+ * All rights reserved.
+ * Redistribution and use of this software in source and binary forms, with or
+ * without modification, are permitted provided that the following conditions
+ * are met:
+ * - Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * - Neither the name of salesforce.com, inc. nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission of salesforce.com, inc.
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.salesforce.samples.authflowtester
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.salesforce.samples.authflowtester.testUtility.AuthFlowTest
+import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.BEACON_OPAQUE
+import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_OPAQUE
+import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.ADVANCED_AUTH
+import com.salesforce.samples.authflowtester.testUtility.KnownLoginHostConfig.REGULAR_AUTH
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Tests for the Welcome Discovery login flow.
+ *
+ * Welcome Discovery normally requires the user to receive a code by email and
+ * use a server-allow-listed consumer key. These tests use the SDK's Login
+ * Options "Discovery Result Editor" to inject a simulated discovery result
+ * (login hint + My Domain), then drive the same code path the real callback
+ * URL would have produced. Mirrors iOS `WelcomeLoginTests.swift`.
+ */
+@RunWith(AndroidJUnit4::class)
+@LargeTest
+class WelcomeLoginTests : AuthFlowTest() {
+
+ /** Welcome discovery with regular auth host and static config. */
+ @Test
+ fun testWelcomeDiscovery_RegularAuthLoginHost() {
+ loginAndValidate(
+ knownAppConfig = ECA_OPAQUE,
+ knownLoginHostConfig = REGULAR_AUTH,
+ useWelcomeDiscovery = true,
+ )
+ }
+
+ /** Welcome discovery with advanced auth host and static config. */
+ @Test
+ fun testWelcomeDiscovery_AdvancedAuthLoginHost() {
+ loginAndValidate(
+ knownAppConfig = BEACON_OPAQUE,
+ knownLoginHostConfig = ADVANCED_AUTH,
+ useWelcomeDiscovery = true,
+ )
+ }
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt
index 14e5c3cafa..73f4bb584e 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginOptionsPageObject.kt
@@ -128,6 +128,32 @@ class LoginOptionsPageObject(composeTestRule: ComposeTestRule): BasePageObject(c
composeTestRule.waitForIdle()
}
+ /**
+ * Flips the Simulate Discovery Result toggle on, types host + username, taps Save.
+ * Mirrors iOS `LoginOptionsPageObject.configure(...)`. Tapping Save is what arms
+ * `SalesforceSDKManager.simulatedDiscoveryResult`; the editor's Save -> manager wiring
+ * is independently covered by the unit test
+ * `LoginOptionsActivityTest.discoveryResultEditor_saveButton_armsSdkManagerSimulatedResult`.
+ */
+ fun setSimulatedDiscoveryResult(loginHost: String, username: String) {
+ toggleIfOff(getString(R.string.sf__login_options_discovery_toggle_content_description))
+
+ composeTestRule.onNodeWithContentDescription(
+ getString(R.string.sf__login_options_discovery_login_host_field_content_description)
+ ).performScrollTo().performTextReplacement(loginHost)
+
+ composeTestRule.onNodeWithContentDescription(
+ getString(R.string.sf__login_options_discovery_username_field_content_description)
+ ).performTextReplacement(username)
+
+ closeSoftKeyboard()
+
+ composeTestRule.onNodeWithContentDescription(
+ getString(R.string.sf__login_options_discovery_save_button_content_description)
+ ).performScrollTo().performClick()
+ composeTestRule.waitForIdle()
+ }
+
private fun isToggleOn(contentDescription: String): Boolean =
composeTestRule.onNodeWithContentDescription(contentDescription)
.fetchSemanticsNode()
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
index 7509170b74..7e92b91101 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/pageObjects/LoginPageObject.kt
@@ -89,6 +89,19 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com
false
}
+ /**
+ * Welcome Discovery login: the OAuth `login_hint` already pre-filled the username
+ * field on page 1; we still tap Continue to advance to page 2, then enter the password
+ * and submit. Mirrors iOS performWelcomeLogin.
+ */
+ open fun welcomeLogin(knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig) {
+ val (_, password) = testConfig.getUser(knownLoginHostConfig, knownUserConfig)
+ tapLogin()
+ setPassword(password)
+ tapLogin()
+ AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(knownLoginHostConfig)
+ }
+
fun openLoginOptions() {
// Tap "More Options" three-dot menu (Compose IconButton)
composeTestRule.onNodeWithContentDescription(getString(R.string.sf__more_options))
@@ -148,8 +161,15 @@ open class LoginPageObject(composeTestRule: ComposeTestRule): BasePageObject(com
}
fun changeServer(knownLoginHostConfig: KnownLoginHostConfig) {
- val url = testConfig.getLoginHost(knownLoginHostConfig).url
+ changeServerByUrl(testConfig.getLoginHost(knownLoginHostConfig).url)
+ }
+ /**
+ * Selects a server from the server picker bottom sheet by matching its URL substring.
+ * Used for servers that aren't represented in `ui_test_config.json` (e.g.
+ * `welcome.salesforce.com/discovery`).
+ */
+ fun changeServerByUrl(url: String) {
// Tap "More Options" three-dot menu (Compose IconButton)
composeTestRule.onNodeWithContentDescription(getString(R.string.sf__more_options))
.performClick()
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt
index 632c4eb8ca..36c6ef5909 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt
@@ -27,7 +27,9 @@
package com.salesforce.samples.authflowtester.testUtility
import android.Manifest
+import android.content.Intent
import android.os.Build
+import androidx.annotation.VisibleForTesting
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.rules.ActivityScenarioRule
@@ -71,7 +73,12 @@ abstract class AuthFlowTest {
val composeTestRule = createEmptyComposeRule()
@get:Rule(order = 2)
- val activityRule = ActivityScenarioRule(AuthFlowTesterActivity::class.java)
+ val activityRule = ActivityScenarioRule(
+ Intent(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ AuthFlowTesterActivity::class.java,
+ ).putExtra(AuthFlowTesterActivity.EXTRA_IS_UI_TESTING, true)
+ )
val loginOptions = LoginOptionsPageObject(composeTestRule)
val app = AuthFlowTesterPageObject(composeTestRule)
@@ -130,6 +137,7 @@ abstract class AuthFlowTest {
useHybridAuthToken: Boolean = true,
knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH,
knownUserConfig: KnownUserConfig = user,
+ useWelcomeDiscovery: Boolean = false,
) {
val loginPage = when(knownLoginHostConfig) {
REGULAR_AUTH -> LoginPageObject(composeTestRule)
@@ -138,8 +146,11 @@ abstract class AuthFlowTest {
ensureRegularAuthServer()
- if (!useWebServerFlow || !useHybridAuthToken ||
- knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY) {
+ val needsLoginOptions = !useWebServerFlow || !useHybridAuthToken ||
+ knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY ||
+ useWelcomeDiscovery
+
+ if (needsLoginOptions) {
loginPage.openLoginOptions()
@@ -151,18 +162,56 @@ abstract class AuthFlowTest {
loginOptions.disableHybridAuthToken()
}
+ // Set simulated discovery result first - its Save does NOT dismiss the activity,
+ // unlike the boot-config Save below which calls activity.finish().
+ if (useWelcomeDiscovery) {
+ val (username, _) = testConfig.getUser(knownLoginHostConfig, knownUserConfig)
+ val targetHost = testConfig.getLoginHost(knownLoginHostConfig).url
+ .removePrefix("https://").removePrefix("http://")
+ loginOptions.setSimulatedDiscoveryResult(
+ loginHost = targetHost,
+ username = username,
+ )
+ }
+
if (knownAppConfig == CA_OPAQUE && scopeSelection == EMPTY) {
+ // No boot config override needed; nothing to save in that section.
Espresso.pressBack()
} else {
+ // setOverrideBootConfig taps Save which calls activity.finish().
loginOptions.setOverrideBootConfig(knownAppConfig, scopeSelection)
}
}
- if (knownLoginHostConfig != REGULAR_AUTH) {
- loginPage.changeServer(knownLoginHostConfig)
- }
+ if (useWelcomeDiscovery) {
+ // Drive the flow through the SDK's server picker via Welcome Discovery URL.
+ // The SDK's switchDefaultOrSalesforceWelcomeDiscoveryLogin path consumes the
+ // armed simulatedDiscoveryResult and routes the OAuth authorize URL to the
+ // simulated host with the simulated login_hint. We then complete login with
+ // the standard flow (which retypes the username; the pre-fill is exercised
+ // server-side via the OAuth login_hint parameter).
+ val webViewLoginPage = LoginPageObject(composeTestRule)
+ webViewLoginPage.changeServerByUrl(WELCOME_DISCOVERY_URL)
+
+ // The simulated host determines the surface: regular_auth -> in-app WebView,
+ // advanced_auth -> Chrome Custom Tab. In both cases the OAuth login_hint
+ // already pre-filled the username; only the password step remains.
+ val welcomeLoginPage: LoginPageObject = when (knownLoginHostConfig) {
+ REGULAR_AUTH -> webViewLoginPage
+ ADVANCED_AUTH -> {
+ val chrome = ChromeCustomTabPageObject(composeTestRule)
+ chrome.skipGoogleSignIn()
+ chrome
+ }
+ }
+ welcomeLoginPage.welcomeLogin(knownLoginHostConfig, knownUserConfig)
+ } else {
+ if (knownLoginHostConfig != REGULAR_AUTH) {
+ loginPage.changeServer(knownLoginHostConfig)
+ }
- loginPage.login(knownLoginHostConfig, knownUserConfig)
+ loginPage.login(knownLoginHostConfig, knownUserConfig)
+ }
app.waitForAppLoad()
app.validateUser(knownLoginHostConfig, knownUserConfig)
@@ -170,6 +219,11 @@ abstract class AuthFlowTest {
app.validateApiRequest()
}
+ companion object {
+ @VisibleForTesting
+ const val WELCOME_DISCOVERY_URL = "https://welcome.salesforce.com/discovery"
+ }
+
/**
* Exercises the "Login for Admins" flow: starts on the REGULAR_AUTH server (in-app
* WebView), opens the overflow menu, taps "Login for Admins" to launch a Chrome
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
index 723945b2f8..38ef8eea53 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
@@ -172,6 +172,11 @@ class AuthFlowTesterActivity : SalesforceActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ // Make debug-only UI test affordances visible when launched by the UI test runner.
+ // Mirrors iOS' IS_UI_TESTING launch argument check in LoginOptionsViewController.swift.
+ SalesforceSDKManager.getInstance().isUiTesting =
+ intent.getBooleanExtra(EXTRA_IS_UI_TESTING, false)
+
setContent {
MaterialTheme(colorScheme = getColorScheme()) {
TesterUI()
@@ -179,6 +184,11 @@ class AuthFlowTesterActivity : SalesforceActivity() {
}
}
+ companion object {
+ /** Intent extra: when true (in a debug build) shows UI-test-only affordances. */
+ const val EXTRA_IS_UI_TESTING = "IS_UI_TESTING"
+ }
+
override fun onResume(client: RestClient?) {
// Keeping reference to rest client
this.client = client
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml
index c2bbf6b7ea..e75e382726 100644
--- a/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/xml/servers.xml
@@ -2,6 +2,7 @@
+