Skip to content

@W-21148602: Internal Server List does not respect changes made to Servers.xml file#2845

Merged
JohnsonEricAtSalesforce merged 14 commits intoforcedotcom:devfrom
JohnsonEricAtSalesforce:feature/w-21148602_internal-server-list-does-not-respect-changes-made-to-servers.xml-file
Mar 6, 2026
Merged

@W-21148602: Internal Server List does not respect changes made to Servers.xml file#2845
JohnsonEricAtSalesforce merged 14 commits intoforcedotcom:devfrom
JohnsonEricAtSalesforce:feature/w-21148602_internal-server-list-does-not-respect-changes-made-to-servers.xml-file

Conversation

@JohnsonEricAtSalesforce
Copy link
Contributor

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce commented Feb 25, 2026

🎸 Ready For Review 🥁

This updates the login server manager to respond to updates made to the login servers from servers.xml in resources and the login servers provided by Mobile Device Management via the runtime configuration. Previously, login servers from these two locations were static (for servers.xml) or additions-only (for runtime config) after the app had been installed.

For a point release, these changes do need to maintain compatibility with the 13.x public API. To minimize regression risk, they're also maintaining the Java style and deprecated shared preferences persistence already in place.

Accomplishing the change is non-trivial. The current logic was not built with mutability in mind for the non-custom servers. Scaling the existing logic and ensuring the shared preferences data maintains exact integrity did increase the change footprint.

New unit tests provide functional automated testing of all the changes and 100% patch code coverage. The overall test coverage for the login server manager is much, much higher than before as well.

I'll follow-up with inline source commentary as well. Thanks for reading!

Context.MODE_PRIVATE);

// Reset non-custom servers from mobile device management (MDM) and servers XML.
resetNonCustomLoginServers(runtimePrefs);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@wmathurin, @brandonpage and @bbirman, before I update the tests and move this into ready-for-review I wanted to run the concept of the change past you all.

I chatted briefly with @bbirman to review the iOS behavior since the work item specifically hails that logic as working compared to Android's. Currently, MSDK Android loads the servers.xml only once on install. The work item mentions upgrade, but I didn't see that logic. More, when MDM provides servers they're additive only. When MDM drops a server, the app doesn't reflect that.

What I'm trying here and seems to manually test well is:

  1. Drop both sets of non-custom servers on startup, including non-custom servers from servers.xml and MDM.
  2. Load the current set of non-custom servers from either servers.xml or MDM according to the existing logic, as much as possible.
  3. When MDM is not enabled, preserve the user's custom login servers at the end of the list

I'm doing a little more testing around the behavior of the only show authorized hosts parameter. I want to be sure the MDM flow still respects the user's custom servers.

The question to answer is: Does this seem like a reasonable update to the existing logic to resolve this work item? I can manipulate servers.xml and the MDM data and the app seems to keep the servers list adds, updates or removes non-custom servers just as we'd want. The logic here is older and the methods are not as self-contained as our newer logic, so I wanted to be sure we all got to review any potential side-effects of this before moving further!

Copy link
Member

Choose a reason for hiding this comment

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

For #3 (and pending your authorized hosts testing), on iOS the custom login servers would be preserved if MDM was enabled as long as "onlyShowAuthorizedHosts" is false

Copy link
Contributor

@brandonpage brandonpage Feb 27, 2026

Choose a reason for hiding this comment

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

This sounds good to me.

One thought though: Should we only remove MDM servers when network is available? Security wise, it would be bad it:

  1. Put device in airplane mode
  2. Open app. MDM servers are deleted and updated servers cannot be retrieved.
  3. Turn airplane mode off.
  4. LoginServerManager constructor does not run again. MDM server list is not respected.

Is that scenario possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks and that thought about network is a great topic. I'll do some research!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I took a manual look and also asked our agents. RuntimeConfig does cache the policy locally and will give MSDK those values when no network is present ✅

@codecov
Copy link

codecov bot commented Feb 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.84%. Comparing base (9e8338d) to head (0406497).
⚠️ Report is 4 commits behind head on dev.

Additional details and impacted files
@@             Coverage Diff              @@
##                dev    #2845      +/-   ##
============================================
+ Coverage     64.55%   64.84%   +0.29%     
- Complexity     2925     2962      +37     
============================================
  Files           222      222              
  Lines         17323    17365      +42     
  Branches       2471     2476       +5     
============================================
+ Hits          11182    11261      +79     
+ Misses         4934     4905      -29     
+ Partials       1207     1199       -8     
Components Coverage Δ
Analytics 47.92% <ø> (ø)
SalesforceSDK 59.61% <98.03%> (+0.58%) ⬆️
Hybrid 59.05% <ø> (ø)
SmartStore 78.20% <ø> (ø)
MobileSync 81.68% <ø> (ø)
React 52.36% <ø> (ø)
Files with missing lines Coverage Δ
...lesforce/androidsdk/config/LoginServerManager.java 97.33% <100.00%> (+22.23%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce force-pushed the feature/w-21148602_internal-server-list-does-not-respect-changes-made-to-servers.xml-file branch from be2307b to f7984d3 Compare March 2, 2026 23:19
…rvers.xml file (Greatly Improved Shared Preferences Data Integrity And Dynamic Handling Of RuntimeConfig. Removal Of To-Dos And Diagnostics Pending)
@JohnsonEricAtSalesforce JohnsonEricAtSalesforce force-pushed the feature/w-21148602_internal-server-list-does-not-respect-changes-made-to-servers.xml-file branch from f7984d3 to 84a6441 Compare March 2, 2026 23:30
…rvers.xml file (Remove Diagnostics And To-Dos)
…rvers.xml file (Slight Self-Review Cleanup, Revert `getLoginServersFromRuntimeConfig` To Maintain Point Release API Compatibility)
@github-actions
Copy link

github-actions bot commented Mar 3, 2026

1 Warning
⚠️ Big PR, try to keep changes smaller if you can.

Generated by 🚫 Danger

…rvers.xml file (Unit Tests For Add, Update And Remove Servers Via Resources)
…rvers.xml file (Unit Tests For Add, Update And Remove Servers Via Runtime Config Mobile Device Management)
…rvers.xml file (Enhancements For Null Safety And Related Unit Tests)
@JohnsonEricAtSalesforce JohnsonEricAtSalesforce force-pushed the feature/w-21148602_internal-server-list-does-not-respect-changes-made-to-servers.xml-file branch from 3e8aa3f to 463b95c Compare March 3, 2026 20:38
…rvers.xml file (Additional Enhancements For Null Safety And Related Unit Tests)
…rvers.xml file (Improve Logic Branching In `getSelectedLoginServer`)
@JohnsonEricAtSalesforce JohnsonEricAtSalesforce force-pushed the feature/w-21148602_internal-server-list-does-not-respect-changes-made-to-servers.xml-file branch from b58c7aa to 686e433 Compare March 4, 2026 01:29
…rvers.xml file (Additional Tests And Improved Branching Logic In `removeServer`)
…rvers.xml file (Improved Null Handling In `getLoginServersFromRuntimeConfig`)
public static final String WELCOME_LOGIN_URL = "https://welcome.salesforce.com/discovery";
public static final String SANDBOX_LOGIN_URL = "https://test.salesforce.com";

// Keys used in shared preferences.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've reviewed the diff in Github once already, so my pre-amble is that Github does a terrible job (as it often does) correlating the diff. If one really wants to navigate the diff more easily, use Android Studio to compare the revisions 🎉

private static final String IS_CUSTOM = "is_custom_%d";
private static final String SERVER_SELECTION_FILE = "server_selection_file";
/**
* Shared preferences when non-custom login servers are provided by resources servers.xml
Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are some new documentation comments to help many of the less-intuitively named properties and methods be clearer and easily reviewed in Android Studio.

/**
* Shared preferences when non-custom login servers are provided by resources servers.xml
*/
@VisibleForTesting
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@VisibleForTesting appears in many places to enable the new tests.

/**
* The Android runtime configuration used for Mobile Device Management (MDM)
*/
private final RuntimeConfig runtimeConfig;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only two properties were added or modified: runtimeConfig is one and present for internal testing support.

private final RuntimeConfig runtimeConfig;

/** The resource id of the servers.xml file */
private final int serversXmlResourceId;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only two properties were added or modified: serversXmlResourceId is one and present for internal testing support.

* @param ctx The context
*/
@VisibleForTesting
public LoginServerManager(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This new and private parameterized constructor is present for testing dependency injection.

runtimePrefs = ctx.getSharedPreferences(RUNTIME_PREFS_FILE, MODE_PRIVATE);

// (Re-)initialize non-custom login servers provided by the resources servers.xml.
resetNonCustomLoginServers(settings);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

A crux change is here in the constructor to (re-)initialize the non-custom login servers from resources server.xml when applicable. Note, this doesn't affect the user's custom login servers.

initSharedPrefFile();

// Select a default login server.
getSelectedLoginServer();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The updated getSelectedLoginServer has new logic to detect when the "selected" login server is no longer in the "available" login servers and select the new default login server.

final boolean selectedServerHasValidNameAndUrl = selectedServerName != null && selectedServerUrl != null;

// Refresh the list of mobile device management (MDM) servers from the runtime config.
if (isRuntimeConfigAppServiceHostsSet()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This refresh of the runtime config login servers wasn't present before but now nicely compliments the following call to getLoginServers so the selected login server will reflect when MDM has removed the currently selected login server. This was an odd looking bug where the "selected" login server might no longer appear in the server picker after an MDM update.

final List<LoginServer> loginServers = getLoginServers();

// Check if the selected login server is available in the active list of login servers.
final boolean selectedLoginServerIsAvailable = loginServers.stream().anyMatch(server ->
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Our agent tooling recommended this stream approach to search the list in Java. This'll be easier in Kotlin, someday.

* @param url The login server URL
*/
public void addCustomLoginServer(String name, String url) {
public void addCustomLoginServer(@NonNull String name, @NonNull String url) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Though it hadn't been reported, passing null for these parameters was a potential NullPointerException.

} else {
persistLoginServer(name, url, true, runtimePrefs);
}
persistLoginServer(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new getSharedPreferences() method lets this be one call to persistLoginServer with the correct SharedPreferences.

public void removeServer(LoginServer server) {
List<LoginServer> servers = getLoginServers();
@VisibleForTesting
public void removeServer(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This private and parameterized version of removeServer supports testing.

allServers = getLoginServersFromPreferences(runtimePrefs);
}
return allServers;
return getLoginServersFromPreferences(getSharedPreferences());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

getLoginServers also gets a lot simpler since it can have one call to getLoginServersFromPreferences with the correct SharedPreferences parameter.

final String url = mdmLoginServers[i];

// Reset non-custom servers from Mobile Device Management (MDM).
resetNonCustomLoginServers(runtimePrefs);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another crux change is here in getLoginServersFromRuntimeConfig to reset the non-custom login servers from that SharedPreferences so that strange things don't happen as the MDM data changes. My testing from dev showed that adding servers worked but deleting servers had no effect. This also meant that "editing" a server resulted in a duplication. The list always reflects MDM now.


// Null-cleanse MDM login server URLs and names.
final List<String> mdmLoginServersList = asList(mdmLoginServers);
mdmLoginServersList.removeIf(Objects::isNull);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Our agent tools recommended this approach for null-cleansing the MDM data which aided in simplifying the tests and avoiding code coverage noise.


// Edit each login server indexed after the updated index.
final Editor editor = settings.edit();
final Editor editor = getSharedPreferences().edit();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was a latent bug where the wrong SharedPreferences was hard-coded even when MDM was active.

* @param sharedPreferences The login server shared preferences
* @return The adjusted login server index
*/
private @NonNull Integer getIndexAdjustedToCustomLoginServerBounds(
Copy link
Contributor Author

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce Mar 5, 2026

Choose a reason for hiding this comment

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

These utility methods to adjust a login server's index to the appropriate bounds of non-custom or custom servers are useful, but for future when SharedPreferences is replaced it'd be amazing to see the custom servers stored separately from the non-custom ones entirely.

eventType = xml.next();
} catch (Exception e) {
SalesforceSDKLogger.w(TAG, "Exception thrown while parsing XML", e);
break;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Without this break, this exception turned into infinite recursion and an app hang which I caught in testing while adding coverage to this (previously?) untested line.

edit.putString(String.format(Locale.US, SERVER_URL, i), curServer.url.trim());
edit.putBoolean(String.format(Locale.US, IS_CUSTOM, i), curServer.isCustom);
if (i == 0) {
persistLoginServer(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All login server persistence happens now via persistLoginServer since it has all the logic needed to correctly order and sanitize the SharedPreferences.

setSelectedLoginServer(curServer);
}
}
edit.putInt(NUMBER_OF_ENTRIES, numServers);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This NUMBER_OF_ENTRIES update happens in persistLoginServer now.

private void persistLoginServer(String name, String url, boolean isCustom, SharedPreferences sharedPrefs) {
if (name == null || url == null) {
return;
private void persistLoginServer(final @NonNull String name,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another big crux change is here to persistLoginServer which now has the ability to correctly order non-custom and custom login servers and all the other login servers. This is where the most work actually happened to make the non-custom login server list mutable.

int numServers = prefs.getInt(NUMBER_OF_ENTRIES, 0);
if (numServers == 0) {
return null;
return new ArrayList<>();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I left most other methods returning null if the did on dev but this one actually made sense to eliminate the null checks at all the private call sites and simplify the logic and tests.


@RunWith(AndroidJUnit4::class)
@SmallTest
class LoginServerManagerTestKt {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Where mockk was needed, I wrote new tests directly in Kotlin.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe rename this one to LoginServerManagerMockTest?

At the very least let's make the file name match the class so we don't have LoginServerManageTest.java and LoginServerManageTest.kt.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce marked this pull request as ready for review March 5, 2026 04:23
@JohnsonEricAtSalesforce
Copy link
Contributor Author

Testing Notes: I tested this via both manual testing on device and via all the new unit tests. In particular, I also used a temporary method to log the contents of both SharedPreferences during testing so I could be 100% sure the data looked rock solid. That was really tedious, but it did reveal interesting data quality issues that unit tests and even manual testing could miss - there was one bug where data indexed above the number of servers value was orphaned after edits. The unit tests and even the app didn't detect that, but I saw it by putting my own eyes on it ✅

Testing requires restarting the app after:

  1. Adding to servers.xml
  2. Editing servers.xml
  3. Removing from servers.xml
    ...And since I didn't have a managed device for the MDM tests I had to update the values obtained from RuntimeConfig in the code to...
  4. Add servers
  5. Edit servers
  6. Remove servers

Each of these tests should be tested with a cold start of the app to ensure the (re-)initialization of the LoginServerManager correctly restores the servers.

Each of these tests should be performed with and without the presence of custom login servers.

The unit tests provide more destructive testing, such as inserting null values and malformed XML in all kinds of places they shouldn't be. I spent five days in manual testing and automated testing development since I'd view this as a higher risk change. This was where I found and resolved a number of NullPointerException crashes and one infinite hang of the app when XML is malformed. 😅


@RunWith(AndroidJUnit4::class)
@SmallTest
class LoginServerManagerTestKt {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe rename this one to LoginServerManagerMockTest?

At the very least let's make the file name match the class so we don't have LoginServerManageTest.java and LoginServerManageTest.kt.

…rvers.xml file (Rename Kotlin/Mockk Tests For `LoginServerManager` To `LoginServerManagerMockTest.kt`)
@JohnsonEricAtSalesforce JohnsonEricAtSalesforce merged commit 2d527da into forcedotcom:dev Mar 6, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants