Using iOS Settings Bundles to Quickly Switch Between Developer Environments

Adrian_AGL
AGL Moderator
0 Replies 154 Views

Ever had this happen? You've poured your heart and soul into preparing a regression build for the QA team to test the upcoming mobile app release. The deployment pipeline is humming along smoothly, and just when you think you're in the clear, a message lands in your inbox:

 

QA Team: Actually, we needed a build pointing to QA, but this one's set for development.

Me: 🤦‍

Navigating multiple environments can be like wandering through a maze, and miscommunication feels like an all-too-familiar foe. As if that weren't enough, the process of compiling and deploying new builds every time you need to switch environments can turn into a colossal time sink for developers.

But what if I told you there's a better way? (Spoiler alert: There is and its awesome!) Let's embark on a journey to streamline this process and make life a whole lot easier for developers and QA teams alike.

 

What Did We Do?

 

So, how did we solve the tangled web of environment-switching woes? The answer lies in the use of Swift's Settings Bundle. This bundle allows you to create a customisable settings screen that can include various user inputs to configure our app.

 

Screenshot 2024-02-09 at 2.09.19 pm.png

Using this, we are able to construct a settings page that allows an enviroment to be changed with just a few taps or clicks, eliminating the need for time-consuming rebuilds or reconfigurations, and we're excited to show you how.

 

How did we do it?

 

STEP 1: Creating and accessing the Settings Bundle

Using Settings Bundle, setting up custom settings in your app is simple. To create a Settings Bundle, Open Xcode > New File > Settings Bundle. Create the bundle making sure not to change the name from 'Settings' (otherwise it won't be added to your app's settings page).

Your bundle will be auto-populated with a Root.plist file and an en.lprog localizations folder. Given this is just for internal use, we will only need the Root.plist file. There are plenty of great tutorials about setting up more complex settings structures (like this one from Kodeco), but for this simple case, we can just add in our values to the Root.plist file directly.

Below is an example of the contents of a Root.plist file that contains a Multi-value Specifier allowing someone to select between Dev, QA/UAT, or Production.

 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "<http://www.apple.com/DTDs/PropertyList-1.0.dtd>">
<plist version="1.0">
<dict>
	<key>StringsTable</key>
	<string>Root</string>
	<key>PreferenceSpecifiers</key>
	<array>
		<dict>
			<key>Type</key>
			<string>PSMultiValueSpecifier</string>
			<key>Title</key>
			<string>Environment</string>
			<key>Key</key>
			<string>dev.environment</string>
			<key>DefaultValue</key>
			<string>dev</string>
			<key>Values</key>
			<array>
				<string>dev</string>
				<string>qa</string>
				<string>prod</string>
			</array>
			<key>Titles</key>
			<array>
				<string>Dev</string>
				<string>QA/UAT</string>
				<string>Production</string>
			</array>
		</dict>
	</array>
</dict>
</plist>​

 

ezgif-4-5581d8c13e.jpg ezgif-4-c97ab44eea.jpg
 
To access the selected enviroment, this can be done via the UserDefaults.standard object. An important note though, your Settings Bundle plist key won't have an associated value until it is changed. So in the case of it not existing, we will need to decode the plist and access the default value manually.
 
func getEnvironment() throws -> String {
	guard 
		let selectedEnvironment = UserDefaults.standard.value(forKey: "environment") as? String 
	else {
		// Value for "environment" doesn't exist, so decode the plist and access the default value
  		guard
    		let url = Bundle.main.url(forResource: "Settings.bundle/Root", withExtension: "plist"),
    		let data = try? Data(contentsOf: url),
    		let plist = try? PropertyListDecoder().decode(YourPlistStructure.self, from: data),
    		let item = plist.items.first(where: { $0.key == "environment" }),
    		let value = item.defaultValue else {
      	throw Error.Invalid
    	}
  		return value
	}
	return selectedEnvironment
}
 

From here you can map that string into some enviroment representing datatype (like an enum) and we are able to start constructing our network calls.

 

STEP 2: Adjusting our network calls

Now that we've set up our environment selection mechanism, the next step is to adjust our network calls to use the selected environment.

In this solution, we assume that you have a base domain that changes for each environment, but all the endpoints remain the same regardless of the environment. For example:

For this scenario, you can simply swap out the evironment part of the domain and keep the rest of the API endpoint the same. One way to do this is to list all your environments and their respective base URLs in a separate plist file. Here's an example using the above API endpoints:

 
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "<http://www.apple.com/DTDs/PropertyList-1.0.dtd>">
<plist version="1.0">
<dict>
	<key>dev</key>
	<dict>
		<key>endpoint</key>
		<string><https://domain-dev.com.au</string>>
	</dict>
	<key>qa</key>
	<dict>
		<key>endpoint</key>
		<string><https://domain-qa.com.au</string>>
	</dict>
	<key>prod</key>
	<dict>
		<key>endpoint</key>
		<string><https://domain-prod.com.au</string>>
	</dict>
</dict>
</plist>
 

Now, using the selected environment we obtained previously, we can query this plist for the correct base URL and construct our API calls.

 
struct Endpoint: Codable {
  var endpoint: String
}
  
func getBaseURL(for environment: String) throws -> URL? {
    guard
      let resource = Bundle.main.url(forResource: "Environments", withExtension: "plist")
    else {
      return nil
    }
    
    let data = try Data(contentsOf: resource, options: .mappedIfSafe)
    let environments = try PropertyListDecoder().decode([String: Endpoint].self, from: data)

    guard
      let endpoint = environments[environment]?.endpoint
    else {
      return nil
    }
    return URL(string: endpoint)
}
 

And voila, we now have our base URL, we can append the remainder of the API path and have a complete request.

 
func constructURL(withPath path: String) -> URL? {
  URL(string: path, relativeTo: try? getBaseURL(for: getEnvironment()))
}
 

Then, assuming we are targeting the Dev environment, calling the constructURL function with the '/profile' path will return us https://domain-dev.com.au/profile.

 

STEP 3: Making this release safe

Now, obviously, we don't want our end users to be able to swap the environment the app is running on. Therefore, we will need to disable the settings bundle and default to the production environment when creating release builds.

You can achieve this by running the following script, which deletes the Settings Bundle. It's best included as part of a release CI pipeline:

 
settings_path="Settings.bundle"

if [[ -f "$settings_path" ]]; then
    rm -rf "$settings_path"
else
    echo "warning: Unable to find '${settings_path}'!"
    exit
fi

Finally, in our code, we can call our getEnvironment with a try? to handle potential errors, such as if Settings.bundle doesn't exist. This way, we'll default to using the production environment:

 

let environment = try? getEnvironment() ?? productionEnviroment
 

This ensures that the production environment is always used in release builds.

 

Considerations

 

While this bundle is pretty easy to set up and use, there are some things worth considering to save you from potentially finding yourself in a sticky situation. Some of these points have been mentioned above already, but they are important enough to be worth reiterating.

As mentioned earlier, your UserDefaults.shared object won't have any reference to your environment key until the value is changed in the settings. That's why, in our getEnvironment function, when the key didn't exist, we had to manually decode the plist and access the default value. Yes, you can simply hard code the default value in your code to save some extra coding, but then you'll have two separate default values that could cause confusion for you and others down the line. In this case, a bit of extra code is worth it for clarity.

Another point worth explicitly mentioning is that since we are accessing these values from UserDefaults.shared, the values are actually stored there (obvious, right? 🤯). But this comes with the caveat that you are not the only ones storing values in there. So, it's always worth double-checking that you won't be overriding any existing values and causing yourself trouble later.

The final consideration is whether you should check your environment when the app launches or each time it returns to the foreground. While updating it on foreground entry might seem attractive, it adds extra code and risks putting you in weird states by making requests with different environments in the same session. I recommend implementing it in the AppDelegate during a new session launch. Yes, it requires restarting the app, but this ensures that each session uses a single environment, reducing code complexity and potential issues.

 

Wrap up

 

In the ever-evolving world of app development, we've tackled the age-old challenge of handling multiple environments with style and ease. By harnessing the power of Swift's settings bundles, we've transformed the once-daunting task of switching between development playgrounds into a seamless dance.

With a few taps in your app's settings, you can go from Dev to QA, or even reach for the stars in Production – all without breaking a sweat. Sure, you might have to give your app a little nudge by closing and reopening it, but hey, we all need a break sometimes, right?

But wait, there's more! This is just the beginning of the settings bundle journey. The beauty of settings bundles lies in their versatility. Beyond handling environments, you can tweak a plethora of app settings – from feature flags and debug logs to enabling or disabling animations. If it's configurable, it can find a home in your settings bundle.

So, go ahead, embrace the power of environments! Keep your codebase tidy, your network calls clean, and your users delighted. With this newfound knowledge, you're ready to conquer the app development universe, one environment at a time.

Happy coding!

0 REPLIES 0