Playground – Part 2: Settings with Options pattern

Playground2

This article is part of a series called Playground:

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:

    • 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 witch 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:

Then, let’s code (all into the Standard project)!

APPSETTINGS

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?

BONUS

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 witch 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.

USERSETTINGS
We often need to save some values on device, so we could get it back anytime, no matter of app lifecycle.
We could achieve that with settings plugins or even Essentials preferences api but guess what? Shiny’s built-in Settings implementation can do the job with some more magic inside, and I do like magic!
The magic here comes from Shiny keeping sync our settings properties with matching device preferences, as long as properties notify changes. It means that decorating our settings properties with the Reactive attribute let Shiny know that we want it to be sync with device preferences. Easier to understand with some code to read, I know.
 
Actually, when talking about UserSettings or SessionSettings, we are talking about editable settings.
As both of it could be registered as many different section classes, we need to share some common apis.
I’ll create some top level abstract base classes to share what could be.
 
First create an EditableSettingsBase.cs file under Settings folder with this content:
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
So we’ll be able to set any editable property to its default type value or to any value defined with System.ComponentModel.DefaultValueAttribute decoration.
In case we’d like to keep some properties away from clearing, we could decorate it with a custom Unclearable attribute.
In case we want to clear it all, just set includeUnclearables to true when calling Clear method.
 
This was our base class, shared by UserSettings and SessionSettings.
Now let’s implement it into a dedicated UserSettingsBase abstract class, shared only by all UserSettings section classes.
 
Create a UserSettings folder into the Settings one.
Create a UserSettingsBase.cs into it with this implementation:
/// <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.

SESSIONSETTINGS

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?

  1. I’m lazy (don’t you know it yet?), so I don’t want to write more than once its lifecycle management witch is almost the same as the UserSettings one, except persistence.
  2. 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.

BONUS

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.

CONCLUSION

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.

 

 

JeremyBP

Specialized since 2013 in cross-platform applications development for iOS, Android and Windows, using technologies such as Microsoft Xamarin and Microsoft Azure. Initially focused, since 2005, on development, then administration of Customer Relationship Management systems, mainly around solutions such as Microsoft SharePoint and Microsoft Dynamics CRM.

Related Posts

Leave a comment

14 − 4 =