This article is part of a series called Playground:
- Playground – Part 1: A Xamarin.Forms Microsoft.Extended journey
- Playground – Part 2: Settings with Options pattern (this one)
- Playground – Part 3: Catch it, trace it, log it and upload it
In the previous article, I created an empty project with Xamarin.Forms, Prism, Shiny and ReactiveUI all wired together.
Now, one of the first thing we do need is managing settings.
As we get access to IServiceCollection in our Xamarin.Forms app thanks to Prism and Shiny, the door is wide open to use any extensions available for it.
One of those are Microsoft.Extensions.Options and Microsoft.Extensions.Configuration.
We’ll try to find out how it could be useful for settings management.
When talking about Settings, in my opinion, we’re actually talking about 3 types of it:
- AppSettings: this one is about app configuration with some readonly values like AppCenter secret key, api url, anything built-in and provided to the app. I guess most of us manage AppSettings with constants, some other with xaml or json file deserialization, and maybe we can achieve that with another way.
- UserSettings: this one is about what the user could set to and get from its device preferences, directly or not. It’s editable and persistant settings we do all know and used to play with.
- SessionSettings: this one is about saving some values, but only for app lifetime using. A singleton registered object kept in-memory, hosting some properties used by our viewmodels, but with default values each time app starts
Coming from Part – 1, I started to read some doc about Options pattern and I have to admit that this make sens to me:
“The options pattern uses classes to provide strongly typed access to groups of related settings. When configuration settings are isolated by scenario into separate classes, the app adheres to two important software engineering principles:
-
- The Interface Segregation Principle (ISP) or Encapsulation: Scenarios (classes) that depend on configuration settings depend only on the configuration settings that they use.
-
- Separation of Concerns: Settings for different parts of the app aren’t dependent or coupled to one another.”
So my goal here is to be able to inject/resolve IOptions<TWhateverSettings>, where TWhateverSettings is a group of related settings, no matter if technically speaking it’s about AppSettings, UserSettings or SessionSettings under the wood.
When I say group, I mean group of properties which for example could be:
- AppCenterSettings with its secret key and some activation booleans (AppSettings under the wood)
- UserAccountSettings with Id, email or auth token (UserSettings under the wood)
- AnyUsefulSessionSettings (SessionSettings under the wood)
No more single class hosting everything, all mixed.
Then I could get for example IOptions<AppCenterSettings> from where and when I need it, with no other useless properties out of scope, and no matter about what’s technically implemented behind the scene.
I’m calling it with Settings suffix right here, but you definitely can name it with Options suffix or whatever suits to you.
Ok so to do that, we need to install these packages from NuGet into the Standard project:
- Microsoft.Extensions.Configuration.Json
- Microsoft.Extensions.Options.ConfigurationExtensions
- Microsoft.Extensions.Options.DataAnnotations (optional for checking value validity)
Then, let’s code (all into the Standard project)!
Create a Settings folder, then an AppSettings sub folder with a SettingsFiles sub folder.
Into the SettingsFiles folder, create these files:
- appsettings.json
- appsettings.debug.json
- appsettings.release.json
Don’t forget to set build action to Embedded resource to all of it.
Leave the appsettings.json file empty and edit the appsettings.debug.json one to make it look like:
{ "AppCenterSettings": { "Secret": "ios={Your iOS App secret here};android={Your Android App secret here}", "TrackCrashes": true, "TrackEvents": false }, "SomeAppSettings": { "Key1": "Debug_value1", "Key2": 0, "Key3": true } }
Update the appsettings.release.json file to:
{ "AppCenterSettings": { "Secret": "ios={Your iOS App secret here};android={Your Android App secret here}", "TrackCrashes": true, "TrackEvents": true }, "SomeAppSettings": { "Key1": "Release_value1", "Key2": 0, "Key3": true } }
Obviously, all of this content is for demonstration purpose only. Feel free to write your real AppSettings in it.
We can notice that we get one appsettings.json file per configuration, I mean debug and release but you must call it the same as your configuration names.
The one with no configuration name will be the one actually used by the app, after being overridden by the matching configuration one at build time. Useless to modify its content as you can guess.
Speaking of overriding files, let’s do this by editing the csproj itself and add:
<Target Name="CopySettingsFiles" AfterTargets="PrepareForBuild"> <Copy SourceFiles="$(MSBuildProjectDirectory)\Settings\AppSettings\SettingsFiles\appsettings.$(Configuration).json" DestinationFiles="$(MSBuildProjectDirectory)\Settings\AppSettings\SettingsFiles\appsettings.json" /> </Target>
Here you can see how we pick the configuration matching settings file to override the main one.
At this point, you should see the appsettings.json file content changing right after building with a different configuration.
Now we have to create a class representing each json section into the AppSettings folder.
Here is the AppCenterSettings.cs:
public class AppCenterSettings { public string Secret { get; private set; } public bool TrackCrashes { get; private set; } public bool TrackEvents { get; private set; } }
And the SomeAppSettings.cs one:
public class SomeAppSettings { [RegularExpression(@"^[a-zA-Z0-9;=_''-'\s]{1,100}$")] public string Key1 { get; private set; } [Range(0, 1000, ErrorMessage = "Value for {0} must be between {1} and {2}.")] public int Key2 { get; private set; } public bool Key3 { get; private set; } }
This one have some properties decorated with some validation attributes, only to say it’s possible to apply some validation checks on values while deserializing the json file.
All AppSettings classes properties get private setters as we don’t wan’t it to be modified after loading.
Ok so we get our json files and our section matching classes.
What we’ll have to do now is registering it all from a Shiny module, like we did in Part-1 for Xamarin.Essentials, remember?
From this module, we’ll register anything we need about our Settings using Options pattern into our container, with the help of IServiceCollection.
So please create a SettingsModule.cs file into the Modules folder and make it look like:
public class SettingsModule : ShinyModule { public override void Register(IServiceCollection services) { // AppSettings (loaded from embedded json settings files to readonly properties) var stream = Assembly.GetAssembly(typeof(App)).GetManifestResourceStream($"{typeof(App).Namespace}.Settings.AppSettings.SettingsFiles.appsettings.json"); if (stream != null) { var config = new ConfigurationBuilder() .AddJsonStream(stream) .Build(); // Add all settings sections here services.Configure<AppCenterSettings>(config.GetSection(nameof(AppCenterSettings)), options => options.BindNonPublicProperties = true); services.AddOptions<SomeAppSettings>() .Bind(config.GetSection(nameof(SomeAppSettings)), options => options.BindNonPublicProperties = true) .ValidateDataAnnotations(); } } }
As you can see:
- First we load a stream from our main appsettings.json, the famous one overridden by the one matching the build configuration. We start from the App.xaml.cs top namespace to go deep to the actual json file location. Don’t forget to make it match your actual namespace if it’s not the same as mine.
- Then we build a ConfigurationBuilder instance with our json stream
- From here we can add all our settings sections to the container:
- I added AppCenterSettings calling the Configure extension method and binding its properties from the AppCenterSettings json section
- SomeAppSettings was added by calling the AddOptions extension method as we also want to call the ValidateDataAnnotations extension method to handle our validation attributes
- I set BindNonPublicProperties to true as our properties are all private
- The config.GetSection method need the name of the section you want to bind your properties from. It could be anything you put into your json file but I feel like the name of my section class is a quitte simple convention
Finally, we just have to register this module into the container from the Startup:
public partial class Startup : ShinyStartup { public override void ConfigureServices(IServiceCollection services) { // Add Xamarin Essentials services.RegisterModule<EssentialsModule>(); // Add Settings services.RegisterModule<SettingsModule>(); } }
*partial is optional, depending if you followed the classic or magic way in Part-1.
At this point you should be able to inject/resolve any AppSettings options like IOptions<AppCenterSettings> into/from anywhere so you get access to AppCenter specific and exclusive readonly settings.
You see Interface Segregation Principle and Separation of Concerns?
Ok so now, AppSettings are registered right after being binded from our json files, with build configuration sensitivity.
This is useful when dealing with kind of production versus development api endpoints for example, meaning same property but with a different value depending on build configuration.
But sometimes, we need some AppSettings properties to be platform sensitive to.
This is already handled by AppCenter sdk for its secret key which is a string written as:
"ios={Your iOS App secret here};android={Your Android App secret here}"
But what if I want to handle it for any other use case?
For example, it could be so much useful when playing with Google Ads keys where you need a different key if you’re working in development or production mode and with iOS or Android platform.
Of course, I could repeat myself creating one property for each platform like AndroidAdsKey and iOSAdsKey plus some if RuntimePlatform somewhere for all of it, or implement it on platform project and make some IoC, but come on… I want my settings to be both configuration and platform agnostic, so I don’t have to think about it when using it.
Let’s say that AppCenter secret key format is the format pattern to handle this case.
Open you appsettings.debug.json and update the SomeAppSettings’s Key1 value with:
"ios=Debug_iOS_value1;android=Debug_Android_value1;uwp=Debug_UWP_value1"
Repeat the operation but with appsettings.release.json:
"ios=Release_iOS_value1;android=Release_Android_value1;uwp=Release_UWP_value1"
To be clear, what I want is getting only Debug_Android_value1
when playing with Android in Debug and Release_iOS_value1
with iOS in Release mode.
To do that, I created an abstract AppSettingsBase class like so:
public abstract class AppSettingsBase { private readonly Lazy<IDeviceService> _lazyDeviceService; protected AppSettingsBase() { // This one can't be injected as constructor must be parameter-less _lazyDeviceService = ShinyHost.LazyResolve<IDeviceService>(); } /// <summary> /// Extract current platform value from a merged formatted key aka android={android_value};ios={ios_value} /// </summary> /// <param name="sourceValue">The formatted source key</param> /// <returns>The current platform value</returns> protected string GetPlatformValue(ref string sourceValue) { if (string.IsNullOrWhiteSpace(sourceValue) || !sourceValue.Contains(";") || !sourceValue.Contains("=")) return sourceValue; return sourceValue = sourceValue.Split(';') .Select(x => x.Split('=')) .FirstOrDefault(x => x[0] == _lazyDeviceService.Value.RuntimePlatform .ToString() .ToLower())?[1]; } }
*replace _lazyDeviceService.Value.RuntimePlatform.ToString().ToLower() by Device.RuntimePlatform.ToLower() with no need of the _lazyDeviceService field and constructor if you followed the classic way in Part-1
It’s only about splitting the string to extract what we need.
Now adjust your AppSettings section classes to implement this method where you need it, like for SomeAppSettings.cs:
public class SomeAppSettings : AppSettingsBase { private string _key1; [RegularExpression(@"^[a-zA-Z0-9;=_''-'\s]{1,100}$")] public string Key1 { get => GetPlatformValue(ref _key1); private set => _key1 = value; } [Range(0, 1000, ErrorMessage = "Value for {0} must be between {1} and {2}.")] public int Key2 { get; private set; } public bool Key3 { get; private set; } }
GetPlatformValue will do the job the first time you try to get the property value, extracting the value you asked for, without explicitly asking it 🙂
You might be thinking we’d better do this job with a JsonConverter, decorating properties… Unfortunately, it’s not supported yet by this extension, but seems to be fixed soon. When fixed I’ll probably edit this post to move the feature to a JsonConverter.
public abstract class EditableSettingsBase : ReactiveObject { /// <summary> /// Set default values /// </summary> protected abstract void Init(); /// <summary> /// Clear saved settings and set it back to default values /// </summary> /// <param name="includeUnclearables" >Clear all, including unclearable properties if true (default: false)</param> public abstract void Clear(bool includeUnclearables = false); /// <summary> /// Set properties to default values based on System.ComponentModel.DefaultValueAttribute decoration /// </summary> /// <param name="props">Properties to set (default: null = all)</param> /// <param name="includeUnclearables" >Set all, including unclearable properties if true (default: true)</param> protected void SetDefaultValues(IList<PropertyDescriptor> props = null) { // Get all editable properties if null props ??= GetEditableProperties(); // Iterate through each property foreach (var prop in props) // Set default attribute value if decorated with DefaultValueAttribute if (prop.Attributes[typeof(DefaultValueAttribute)] is DefaultValueAttribute attr) prop.SetValue(this, attr.Value); // Otherwise set default type value else prop.SetValue(this, default); } /// <summary> /// Get all editable properties /// </summary> /// <param name="includeUnclearables" >Get all, including unclearable properties if true (default: false)</param> /// <returns></returns> protected IList<PropertyDescriptor> GetEditableProperties(bool includeUnclearables = false) { return TypeDescriptor.GetProperties(this) .Cast<PropertyDescriptor>() .Where(prop => !prop.IsReadOnly && (includeUnclearables || !(prop.Attributes[typeof(UnclearableAttribute)] is UnclearableAttribute))) .ToList(); } /// <summary> /// Attribute to prevent decorated property from being cleared /// </summary> [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] protected class UnclearableAttribute : Attribute { } }
Ok what could we said about it.
First, we get our lifecyle apis definitions:
- Init: called to set it up with default values (not implemented at this level)
- Clear: to set properties back to default values (not implemented at this level)
Then some management methods and an attribute:
- SetDefaultValues: call GetEditableProperties and set each of it to its System.ComponentModel.DefaultValueAttribute value if decorated with, otherwise return to type default
- GetEditableProperties: actually look for editable properties, including or not the one decorated with our custom Unclearable attribute
- UnclearableAttribute: used to prevent decorated properties from being cleared when Clear method is called with includeUnclearables parameter to false
/// <summary> /// Will sync user settings properties with device preferences key value entries /// Will notify property changed /// On Clear() calls, will return to default attribute value when decorated with DefaultValueAttribute or to default type value if not, /// excepted for those decorated with UnclearableAttribute /// Will save in a secure encrypted way if decorated with Secure attribute /// </summary> public abstract class UserSettingsBase : EditableSettingsBase { private readonly ISettings _settings; protected UserSettingsBase() { // This one can't be injected as constructor must be parameter-less _settings = ShinyHost.Resolve<ISettings>(); // Init settings Init(); // Observe global clearing to set local properties back to default values _settings.Changed .Where(x => x.Action == SettingChangeAction.Clear) .Subscribe(_ => OnGlobalClearing()); } /// <summary> /// Set default values and enable settings sync /// </summary> protected sealed override void Init() { // Set to default values SetDefaultValues(); // Enable settings sync _settings.Bind(this); } /// <summary> /// Clear saved settings and set it back to default values /// </summary> /// <param name="includeUnclearables" >Clear all, including unclearable properties if true (default: false)</param> public override void Clear(bool includeUnclearables = false) { // Get all editable properties var props = GetEditableProperties(includeUnclearables); // Iterate through each clearable property foreach (var prop in props) // Clear property if clearable _settings.Remove($"{GetType().FullName}.{prop.Name}"); // Disable settings sync while returning to default _settings.UnBind(this); // Return to default values SetDefaultValues(props); // Enable settings sync back _settings.Bind(this); } /// <summary> /// Return to default values /// </summary> private void OnGlobalClearing() { // Disable settings sync while returning to default _settings.UnBind(this); // Return to default values SetDefaultValues(); // Enable settings sync back _settings.Bind(this); } }
That’s where Shiny Settings finally comes in.
- On the Init call, we set properties to default values and activate the sync with device preferences calling _settings.Bind(this);
- On Clear call, we return to default values just after clearing matching device preferences.
- On global settings Clear call , we make sure everything is fully cleared everywhere, because yes, despite the fact we’re splitting all in different section classes (remember Separation of Concerns), you can call individual section’s Clear method, or the global ISettings’s Clear one to clear them all (the global Clear will clear everything, including Unclearables).
With these two base classes in place, we can now define our UserSettings section classes, like for example a UserAccountSettings.cs:
public partial class UserAccountSettings : UserSettingsBase { [Reactive, Unclearable] public string UserId { get; set; } [Reactive, DefaultValue("Test")] public string Username { get; set; } [Reactive] public string Email { get; set; } [Reactive, Secure] public string AuthToken { get; set; } }
Oh yes there’s also this Secure attribute to save your value in a secure place if needed 🙂
Finally, don’t forget to update your SettingsModule with your UserSettings sections registrations:
public class SettingsModule : ShinyModule { public override void Register(IServiceCollection services) { // AppSettings (loaded from embedded json settings files to readonly properties) var stream = Assembly.GetAssembly(typeof(App)).GetManifestResourceStream($"{typeof(App).Namespace}.Settings.AppSettings.SettingsFiles.appsettings.json"); if (stream != null) { var config = new ConfigurationBuilder() .AddJsonStream(stream) .Build(); // Add all settings sections here services.Configure<AppCenterSettings>(config.GetSection(nameof(AppCenterSettings)), options => options.BindNonPublicProperties = true); services.AddOptions<SomeAppSettings>() .Bind(config.GetSection(nameof(SomeAppSettings)), options => options.BindNonPublicProperties = true) .ValidateDataAnnotations(); } // UserSettings (sync with device preferences) services.AddOptions<UserAccountSettings>(); } }
At this point you should be able to inject/resolve any UserSettings options like IOptions<UserAccountSettings> into/from anywhere so you get access to UserAccount specific and exclusive persistent settings.
This one could be any class registered as singleton. The the goal here just to save some values in-memory during the app running lifetime.
So why “optioning” it?
- I’m lazy (don’t you know it yet?), so I don’t want to write more than once its lifecycle management which is almost the same as the UserSettings one, except persistence.
- I’m lazy (I think you get it now), so I don’t want to care about naming exception, I mean I just want to tell myself: “Ok I need MySessionSettings thing and it’s about settings as well named, so I know how to get it like all others obviously: IOptions<MySessionSettings>”
Create a SessionSettings folder under the Settings one.
Create a SessionSettingsBase.cs into it and paste:
public abstract class SessionSettingsBase : EditableSettingsBase { protected SessionSettingsBase() { // Init settings Init(); } /// <summary> /// Set default values /// </summary> protected sealed override void Init() { // Return to default values SetDefaultValues(); } /// <summary> /// Clear saved settings and set it back to default values /// </summary> /// <param name="includeUnclearables" >Clear all, including unclearable properties if true (default: false)</param> public override void Clear(bool includeUnclearables = false) { // Get all editable properties if force all mode or clearable one var props = GetEditableProperties(includeUnclearables); // Return to default values SetDefaultValues(props); } }
You can see our SessionSettingsBase abstract class inherits from our previous EditableSettingsBase abstract class.
We just call sealed overridden Init from constructor and implement the Clear method.
Now you can go for any SessionSettings sections like:
public partial class SomeSessionSettings : SessionSettingsBase { [Reactive] public bool Key1 { get; set; } }
At this point you should be able to inject/resolve any SessionSettings options like IOptions<SomeSessionSettings> into/from anywhere so you get access to SomeSession specific and exclusive in-memory settings.
If you guys want to clear all SessionSettings without resolving each section, just change your SessionSettingsBase constructor to:
protected SessionSettingsBase() { // This one can't be injected as constructor must be parameter-less var settings = ShinyHost.Resolve<ISettings>(); // Init settings Init(); // Observe global clearing to set local properties back to default values settings.Changed .Where(x => x.Action == SettingChangeAction.Clear) .Subscribe(_ => SetDefaultValues()); }
This way, calling Clear() from ISettings will clear all UserSettings and SessionSettings at once. Useful when dealing with a Logout scenario.
Here is our Settings layer implemented!
We could make it work along with Mobile.BuildTools into our platform projects (not the configuration package obviously), to add some DevOps capabilities like App manifest tokenization, build versioning, release notes generation, and more…
Next article will talk about logging.
Playground source code is available on GitHub.
As the playground project is constantly moving and growing, be sure to select the corresponding tag, then the commit number and finally the Browse files button to explore the blog post matching version.