Using OneDrive files in Windows Platform Apps – Part Deux

This article presents a handful of building blocks for some more advanced OneDrive use cases in Windows 8 apps, like:

  • transparently switching between a local folder and a OneDrive working folder,
  • synchronizing a local folder with a OneDrive folder,
  • sharing a OneDrive folder across devices,
  • sharing a OneDrive folder across apps, or even
  • sharing a OneDrive folder across platforms (Store app - Phone app - side loaded Enterprise app).

It elaborates on my previous article that showed how to access a OneDrive folder from a Windows 8 Store app from a purely technical point. This code is now hardened, and refactored into a more reusable model. Although all building blocks are implemented and tested, not all of the mentioned scenarios are implemented in the attached sample app. The sample app simulates a Store app that has OneDrive capability as an option (e.g. as an in-app purchase) and that can switch back to the local folder whenever the user feels to (e.g. to work offline). This is how it looks like:

OneDrive

The sample app allows you to do some basic file operations, and comes with a manual switch to toggle between the local folder and a working folder on your OneDrive. File and folder comparison and synchronization are not elaborated, but all constituents are in the source code.

The object model contains the following classes:

  • FileSystemBase: a base class that encapsulates the basic file system operations, regardless of where the working folder lives:
    • enumerating the files in the working folder,
    • saving a file,
    • reading a file, and
    • deleting a file.
  • IFile: an interface that contains the file properties that the app is interested in:
    • name of the file,
    • size of the file (useful for synchronization),
    • modification date of the file (useful for synchronization)
  • Device: a class that represents the physical device:
    • hardware identifier, and
    • internet connectivity.

Here’s the UML class diagram of the API:

API

The FileSystemBase class abstracts the file manipulations. It’s an abstract class with concrete virtual asynchronous methods that each throw an exception. I know that looks weird, but it’s the best way to enforce child classes to implement an expected asynchronous behavior (static, abstract, interface, and async don’t really work together in a class definition):

/// <summary>
/// Base Class for File Systems.
/// </summary>
public abstract class FileSystemBase
{
    /// <summary>
    /// Returns the list of Files in the Working Folder.
    /// </summary>
    public async virtual Task<List<IFile>> Files()
    {
        throw new NotImplementedException();
    }

    /// <summary>
    /// Saves the specified content into the Working Folder.
    /// </summary>
    public async virtual Task Save(string content, string fileName)
    {
        throw new NotImplementedException();
    }

    /// <summary>
    /// Returns the content of the specified file in the Working Folder.
    /// </summary>
    public async virtual Task<string> Read(string fileName)
    {
        throw new NotImplementedException();
    }

    /// <summary>
    /// Deletes the specified file from the Working Folder.
    /// </summary>
    public async virtual Task Delete(string fileName)
    {
        throw new NotImplementedException();
    }
}

Both concrete child classes –LocalDrive and OneDrive- have their own implementation of the virtual methods. The OneDrive class is of course a bit more complex than the LocalDrive class, since it needs a login procedure and it requires and extra identifier for the working folder and its files. Check the sample app for the source code, I'm not repeating it in this article since it’s just a rehash of my previous blog post.

The main viewmodel of the app uses a field to refer to the file system:

private FileSystemBase currentDrive;

The app will not show command buttons as long as it’s not connected to a file system. So I added an IsReady property to the viewmodel:

/// <summary>
/// Gets a value indicating whether this instance is ready (i.e. connected to a file system).
/// </summary>
public bool IsReady
{
    get { return this.currentDrive != null; }
}

Here’s the app in its waiting mode - it may take some time to connect to OneDrive the first time:WaitUntilReady

The last used file system is stored in the roaming settings, so it can be shared between devices:

/// <summary>
/// Gets or sets a value indicating whether we're using OneDrive or Local Folder.
/// </summary>
public bool UseOneDrive
{
    get { return this.useOneDrive; }

    set
    {
        if (value != this.useOneDrive)
        {
            if (value)
            {
                this.TryEnableOneDrive();
            }
            else
            {
                this.currentDrive = LocalDrive.Current;
                this.SetProperty(ref this.useOneDrive, value);
                ApplicationData.Current.RoamingSettings.Values["UseOneDrive"] = value;
                this.OnPropertyChanged("IsReady");
            }
        }
    }
}

When the user switches to OneDrive mode, we try to activate it. In case of a problem (e.g. the user did not consent, or the drive cannot be accessed), we switch back to local mode. The OneDrive initialization code is called from strictly synchronous code - a property setter and a constructor. It executes asynchronously and finishes with property change notifications. The XAML bindings will do the rest:

private void TryEnableOneDrive()
{
    bool success = true;
    CoreWindow.GetForCurrentThread().Dispatcher.RunAsync
        (
            CoreDispatcherPriority.Normal,
            async () =>
            {
                FileSystemBase fileSystem = null;

                try
                {
                    fileSystem = await OneDrive.GetCurrent();
                }
                catch (Exception)
                {
                    success = false;
                }
                finally
                {
                    if (fileSystem == null || !OneDrive.IsLoggedIn)
                    {
                        // Something went wrong, switch to local.
                        success = false;
                        this.currentDrive = LocalDrive.Current;
                    }
                    else
                    {
                        this.currentDrive = fileSystem;
                    }

                    this.useOneDrive = success;
                    ApplicationData.Current.RoamingSettings.Values["UseOneDrive"] = success;

                    // Need to explicitly notify to reset toggle button on error.
                    this.OnPropertyChanged("UseOneDrive");
                    this.OnPropertyChanged("IsReady");
                }
            }
        );
}

Here’s how the code is called from the constructor of the viewmodel:

this.useOneDrive = (bool)ApplicationData.Current.RoamingSettings.Values["UseOneDrive"];

if (this.useOneDrive)
{
    this.TryEnableOneDrive();
}
else
{
    this.currentDrive = LocalDrive.Current;
}

In that same constructor we also check if the app has been used from another device lately, because this might trigger a synchronization routine. The use case that I have in mind here, is an app that always saves locally but uploads at regular intervals to the user’s OneDrive. Such an app would want to be informed that the OneDrive folder was updated by another device:

if (ApplicationData.Current.RoamingSettings.Values["HardwareId"] != null)
{
    string previous = (string)ApplicationData.Current.RoamingSettings.Values["HardwareId"];

    if (previous != Device.Ashwid)
    {
        this.ShowToast("You seem to have used this app from another machine!", "ms-appx:///Assets/Warning.png");
    }
}

ApplicationData.Current.RoamingSettings.Values["HardwareId"] = Device.Ashwid;

Here’s that code in action:

OtherHardware

For checking the device id, a GUID in local settings would do the job in most scenarios. But the ASHWID allows you to share the working folder between apps on the same device (you would just have to override the default working folder name for this):

/// <summary>
/// Gets the Application Specific Hardware Identifier.
/// </summary>
/// <remarks>
/// Due to hardware drift, the returned value may change over time). 
/// See http://msdn.microsoft.com/en-us/library/windows/apps/jj553431.aspx. 
/// </remarks>
public static string Ashwid
{
    get
    {
        HardwareToken hwToken = HardwareIdentification.GetPackageSpecificToken(null);
        IBuffer hwID = hwToken.Id;
        byte[] hwIDBytes = hwID.ToArray();
        return hwIDBytes.Select(b => b.ToString()).Aggregate((b, next) => b + "," + next);
    }
}

Most of the file operations are ignorant of the whereabouts of the working folder:

private async void ReadFolder_Executed()
{
    this.files.Clear();
    foreach (var file in await this.currentDrive.Files())
    {
        this.files.Add(file);
    }
}

private async void ReadFile_Executed()
{
    if (this.selectedFile != null)
    {
        this.SelectedText = await this.currentDrive.Read(this.selectedFile.Name);
    }
}

private async void DeleteFile_Executed()
{
    if (this.selectedFile != null)
    {
        var task = this.currentDrive.Delete(this.selectedFile.Name);

        try
        {
            await task;

            this.ShowToast("File deleted.");
        }
        catch (Exception ex)
        {
            this.ShowToast("There was an error while deleting.", "ms-appx:///Assets/Warning.png");
        }

        this.ReadFolderCommand.Execute(null);
    }
}

While saving a file on OneDrive you can easily pull the internet cable or disable the Wifi, so the Save method was the ideal candidate to test some extra exception handling. If something goes wrong while saving, you may want to check the connection to the Internet. Unfortunately there is no way to ask the LiveConnectClient nor the LiveConnectSession whether the connection is still available. Actually it’s even worse: you can re-login successfully without a connection, you end up with a "false positive". Fortunately you can examine your Internet connectivity in other ways:

/// <summary>
/// Gets a value indicating whether this device is currently connected to the Internet.
/// </summary>
public static bool IsConnected
{
    get
    {
        try
        {
            return NetworkInformation.GetInternetConnectionProfile().GetNetworkConnectivityLevel() >= NetworkConnectivityLevel.InternetAccess;
        }
        catch (Exception)
        {
            return false;
        }
    }
}

In a production app, it would make sense to automatically switch to local mode after a failed Save operation against OneDrive. Apparently the LiveConnectClient enters an undetectable corrupt state after an unsuccessful BackgroundUploadAsync. The next time you try to save a file –and there’s still no Internet connection- the Live client throws an exception that you can’t catch. I’m sorry I didn’t find a workaround for this yet. Anyway, the app will die on you ungracefully in this particular scenario :-(

ClientException

Here’s the whole Save method (the one in the main view model):

private async void SaveFile_Executed()
{
    if (this.selectedFile != null)
    {
        var task = this.currentDrive.Save(this.selectedText, this.selectedFile.Name);

        try
        {
            await task;

            this.ShowToast("File saved.");
        }
        catch (Exception ex)
        {
            if (this.useOneDrive && !Device.IsConnected)
            {
                this.ShowToast("Internet connection is lost. You may want to switch to local storage.", "ms-appx:///Assets/Warning.png");
            }
            else
            {
                this.ShowToast("There was an error while saving.", "ms-appx:///Assets/Warning.png");
            }
        }
    }
}

Here’s one of the toasts that notify the user of a problem:

InternetWarning

Here’s the sample app; it has the “Transparent FileSystem API” in its Models folder. The code was written in Visual Studio 2013 Update 2 RC, but I did not use any of the new Shared Project/Universal App features yet: U2UC.WinRT.OneDriveSync.zip (1.4MB)

Don’t forget to associate the project with an app of yours, or it won’t work:

AssociateStoreApp

Enjoy!

Diederik