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
12 changes: 12 additions & 0 deletions libs/SalesforceSDK/res/values/sf__strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,16 @@
<string name="sf__login_options_redirect_uri_field_content_description">Redirect URI Field</string>
<string name="sf__login_options_scopes_field_content_description">Scopes Field</string>
<string name="sf__login_options_save_button_content_description">Save Button</string>
<string name="sf__login_options_save_and_login">Save and Login</string>

<!-- Welcome Discovery Simulation (Login Options). Mirrors iOS DiscoveryResultEditor strings.
translatable="false" because this editor only appears in debug / dev-support builds. -->
<string name="sf__login_options_discovery_simulate_title" translatable="false">Simulate Discovery Result</string>
<string name="sf__login_options_discovery_toggle_content_description" translatable="false">Toggle Simulate Discovery Result</string>
<string name="sf__login_options_discovery_login_host_label" translatable="false">My Domain Login Host</string>
<string name="sf__login_options_discovery_username_label" translatable="false">User Name</string>
<string name="sf__login_options_discovery_save_button" translatable="false">Save Simulated Result</string>
<string name="sf__login_options_discovery_login_host_field_content_description" translatable="false">Discovery Login Host Field</string>
<string name="sf__login_options_discovery_username_field_content_description" translatable="false">Discovery Username Field</string>
<string name="sf__login_options_discovery_save_button_content_description" translatable="false">Save Simulated Result Button</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ This method should only be accessed from tests or within private scope

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -325,5 +328,70 @@ class LoginActivityTest {
}
}

@Test
fun applySimulatedDiscoveryResult_noArmedResult_returnsFalse() {
SalesforceSDKManager.getInstance().simulatedDiscoveryResult = null
val activity = mockk<LoginActivity>(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<MediatorLiveData<String>>(relaxed = true)
val viewModel = mockk<LoginViewModel>(relaxed = true)
every { viewModel.loginUrl } returns loginUrl
val activity = mockk<LoginActivity>(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<MediatorLiveData<String>>(relaxed = true)
val viewModel = mockk<LoginViewModel>(relaxed = true)
every { viewModel.loginUrl } returns loginUrl
val activity = mockk<LoginActivity>(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
}
Loading
Loading