Solving 3 problems with the ShellTileSchedule

Are the problems with the Shell Tile Schedule? Yes there are, at least I’m in the impression that there are some problems. Although we have those problems I really like the ShellTileSchedule because it enables an app to have an updated tile without the requirement to write server-side code to notify the client for a new tile. The smallest schedule that’s supported is 1-hour. In that situation every hour the tile will be updated with a tile located on the web (static url, which can return a dynamic image of course).

There are three problems I identified so far.

1. You can’t get the status of the ShellTileSchedule. Worse, although you started the schedule it might be stopped because for whatever reason (ex. phone is on Airplane mode) the downloading of the tile failed.

2. You have to wait at least 1 hour before the tile is updated for the first time.

3. After you stopped the ShellTileSchedule, the tile will be the last downloaded tile forever. It would be better if automatically the original tile (from the .xap package) is put back.

Combined a diagram to show the problems.

image

Solution 1 for problem 1

Alright, we can’t get the status. So what do we do? We store it locally, in the Isolated Storage. I really like the approach that’s explained by Joost van Schaik who created some extension methods for storing the settings in the Isolated Storage. He’s storing and retrieving to the Phone State on the Tomb Stoning events: Activated and Deactivated. I did store and retrieve from Isolated on all events: Activated, Deactivated, Launching and Closing.

That’s all about making sure we have a status. But still this status won’t be updated when the schedule stopped. The Windows Phone team suggests to start the schedule on every application start. The code of the Application_Launching event in the App.xaml.cs could look like this.

private void Application_Launching(object sender, LaunchingEventArgs e)
{
    Settings = this.RetrieveFromIsolatedStorage<SettingsViewModel>() ?? new SettingsViewModel();
    if(Settings.TileUpdatesEnabled)
    {
        new ShellTileSchedule
        {
            Interval = UpdateInterval.EveryHour,
            MaxUpdateCount = 0,
            Recurrence = UpdateRecurrence.Interval,
            RemoteImageUri = new Uri(@"http://mark.mymonster.nl/Uploads/2010/12/servertile.png"),
            StartTime = DateTime.Now
        }.Start();
    }
}

Joost mentions that the ViewModelBase of MVVM Light isn’t serializable. So I created a basic ViewModelBase that has the functionality that I’m always using in ViewModelBase (RaisePropertyChanged) and decorated it with the DataContract attribute.

[DataContract]
public class ViewModelBase : INotifyPropertyChanged
{
    #region INotifyPropertyChanged Members

    public event PropertyChangedEventHandler PropertyChanged;

    #endregion

    protected void RaisePropertyChanged(string propertyName)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}

Solution 2 for problem 2

Not many people know it’s possible, but Matthijs Hoekstra from Microsoft helped me in the direction to solve this problem.

Solution: Send a Push Notification from the phone itself. And afterwards close the channel and start the ShellTileSchedule.

It’s important that you send the Push Notification before the actual call to the start the ShellTileSchedule because the ShellTileSchedule is a kind of NotificationChannel of which only one can exist.

A lot of articles have been written already about how to do Push Notifications. After opening a NotificationChannel you will get a url. After that you send some xml to that url. The xml that has to be send looks like this.

?<?xml version="1.0" encoding="utf-8"?>
<wp:Notification xmlns:wp="WPNotification">
  <wp:Tile>
    <wp:BackgroundImage>http://someserver/servertile.png</wp:BackgroundImage>
    <wp:Count>0</wp:Count>
  </wp:Tile>
</wp:Notification>

The setup of the xml in code:

public void SendTile(Uri notificationUrl, string tileUri, int? count, string title, Action onComplete)
{
    var stream = new MemoryStream();
    var settings = new XmlWriterSettings {Indent = true, Encoding = Encoding.UTF8};
    XmlWriter writer = XmlWriter.Create(stream, settings);
    writer.WriteStartDocument();
    writer.WriteStartElement("wp", "Notification", "WPNotification");
    writer.WriteStartElement("wp", "Tile", "WPNotification");
    if (!string.IsNullOrEmpty(tileUri))
    {
        writer.WriteStartElement("wp", "BackgroundImage", "WPNotification");
        writer.WriteValue(tileUri);
        writer.WriteEndElement();
    }
    if (count.HasValue)
    {
        writer.WriteStartElement("wp", "Count", "WPNotification");
        writer.WriteValue(count.ToString());
        writer.WriteEndElement();
    }
    if (!string.IsNullOrEmpty(title))
    {
        writer.WriteStartElement("wp", "Title", "WPNotification");
        writer.WriteValue(title);
        writer.WriteEndElement();
    }
    writer.WriteEndElement();
    writer.Close();
    byte[] payload = stream.ToArray();

	...
}

If you take a look at the Windows Phone 7 Training Kit you will see an example on how to do a Push Notification. It can be used almost one on one in the Windows Phone application itself. I removed some of the code (for the Raw and Toast Notifications) and refactored it to fit my needs.

So after the setup of the xml, the sending of the xml and setting of the HTTP headers looks like this:

public void SendTile(Uri notificationUrl, string tileUri, int? count, string title, Action onComplete)
{
	...
	
	byte[] payload = stream.ToArray();

    //Check the length of the payload and reject it if too long
    if (payload.Length > MaxPayloadLength)
        throw new ArgumentOutOfRangeException(
            string.Format("Payload is too long. Maximum payload size shouldn't exceed {0} bytes",
                            MaxPayloadLength));

    //Create and initialize the request object
    var request = (HttpWebRequest) WebRequest.Create(notificationUrl);
    request.Method = "POST";
    request.ContentType = "text/xml; charset=utf-8";
    //request.ContentLength = payload.Length;
    request.Headers["X-MessageID"] = Guid.NewGuid().ToString();
    request.Headers["X-NotificationClass"] = 1.ToString();
    request.Headers["X-WindowsPhone-Target"] = "token";

    request.BeginGetRequestStream(
        ar =>
            {
                //Once async call returns get the Stream object
                Stream requestStream = request.EndGetRequestStream(ar);

                //and start to write the payload to the stream asynchronously
                requestStream.BeginWrite(
                    payload, 0, payload.Length,
                    iar =>
                        {
                            //When the writing is done, close the stream
                            requestStream.EndWrite(iar);
                            requestStream.Close();

                            //and switch to receiving the response from MPNS
                            request.BeginGetResponse(
                                iarr =>
                                    {
                                        if (onComplete != null)
                                            onComplete();
                                    },
                                null);
                        },
                    null);
            },
        null);
}

Of course we shouldn’t forget the important part: Subscribing to the Notification channel. Take special notice to the Thread.Sleep in line 21. This is to make sure that the Tile update is completed before the unbinding, and more starting the ShellTileSchedule.

private void UpdateTileBeforeOperation(Uri imageUri, Action onComplete)
{
    HttpNotificationChannel channel = HttpNotificationChannel.Find("OneTime");
    if (channel != null)
        channel.Close();
    else
    {
        channel = new HttpNotificationChannel("OneTime");
        channel.ChannelUriUpdated +=
            (s, e) =>
                {
                    if (imageUri.IsAbsoluteUri)
                        channel.BindToShellTile(new Collection<Uri> {imageUri});
                    else
                        channel.BindToShellTile();

                    SendTile(e.ChannelUri, imageUri.ToString(), 0, "",
                                () =>
                                    {
                                        //Give it some time to let the update propagate
                                        Thread.Sleep(
                                            TimeSpan.FromSeconds(1));

                                        channel.UnbindToShellTile();
                                        channel.Close();
                                        //Do the operation
                                        if (onComplete != null)
                                            onComplete();
                                    }
                        );
                };
        channel.Open();
    }
}

Solution 3 for problem 3

The solution for problem 3 is similar to solution 2. But instead of a remote url the url is an relative url, local to the .xap file.

public void Stop(Action onComplete)
{
    UpdateTileBeforeOperation(new Uri("/Background.png", UriKind.Relative),
                                () => { if (onComplete != null) onComplete(); });
}

Again it allows you to include an action that will be called upon completion of the Stop method.

 

Full solution diagram

Alright, all problems solved. The diagram now looks like this.

image

Of course you want to have the full code for the SmartShellTileSchedule.

public class SmartShellTileSchedule
{
    private const int MaxPayloadLength = 1024;

    public UpdateRecurrence Recurrence { get; set; }

    public int MaxUpdateCount { get; set; }

    public DateTime StartTime { get; set; }

    public UpdateInterval Interval { get; set; }

    public Uri RemoteImageUri { get; set; }

    /// <summary>
    /// If the schedule is enabled (store this in application settings) this operation should be 
    /// called upon each application start.
    /// </summary>
    public void CheckForStart()
    {
        DelegateSchedule().Start();
    }

    /// <summary>
    /// This will enable the schedule and make sure the tile is updated immediately. Don't call 
    /// this operation on each application start.
    /// </summary>
    public void Start()
    {
        Start(null);
    }

    /// <summary>
    /// This will enable the schedule and make sure the tile is updated immediately. Don't call 
    /// this operation on each application start.
    /// </summary>
    /// <param name="onComplete">will be called upon completion</param>
    public void Start(Action onComplete)
    {
        UpdateTileBeforeOperation(RemoteImageUri, () =>
                                                        {
                                                            CheckForStart();
                                                            if (onComplete != null) onComplete();
                                                        });
    }

    /// <summary>
    /// This will stop the schedule and make sure the tile is replaced with the original logo-tile.
    /// Assumption is that the logo-tile is called "Background.png"
    /// </summary>
    public void Stop()
    {
        Stop(null);
    }

    /// <summary>
    /// This will stop the schedule and make sure the tile is replaced with the original logo-tile.
    /// Assumption is that the logo-tile is called "Background.png"
    /// </summary>
    /// <param name="onComplete">will be called upon completion</param>
    public void Stop(Action onComplete)
    {
        UpdateTileBeforeOperation(new Uri("/Background.png", UriKind.Relative),
                                    () => { if (onComplete != null) onComplete(); });
    }

    private void UpdateTileBeforeOperation(Uri imageUri, Action onComplete)
    {
        HttpNotificationChannel channel = HttpNotificationChannel.Find("OneTime");
        if (channel != null)
            channel.Close();
        else
        {
            channel = new HttpNotificationChannel("OneTime");
            channel.ChannelUriUpdated +=
                (s, e) =>
                    {
                        if (imageUri.IsAbsoluteUri)
                            channel.BindToShellTile(new Collection<Uri> {imageUri});
                        else
                            channel.BindToShellTile();

                        SendTile(e.ChannelUri, imageUri.ToString(), 0, "",
                                    () =>
                                        {
                                            //Give it some time to let the update propagate
                                            Thread.Sleep(
                                                TimeSpan.FromSeconds(1));

                                            channel.UnbindToShellTile();
                                            channel.Close();
                                            //Do the operation
                                            if (onComplete != null)
                                                onComplete();
                                        }
                            );
                    };
            channel.Open();
        }
    }

    private ShellTileSchedule DelegateSchedule()
    {
        return new ShellTileSchedule
                    {
                        Interval = Interval,
                        MaxUpdateCount = MaxUpdateCount,
                        Recurrence = Recurrence,
                        RemoteImageUri = RemoteImageUri,
                        StartTime = StartTime
                    };
    }

    public void SendTile(Uri notificationUrl, string tileUri, int? count, string title, Action onComplete)
    {
        var stream = new MemoryStream();
        var settings = new XmlWriterSettings {Indent = true, Encoding = Encoding.UTF8};
        XmlWriter writer = XmlWriter.Create(stream, settings);
        writer.WriteStartDocument();
        writer.WriteStartElement("wp", "Notification", "WPNotification");
        writer.WriteStartElement("wp", "Tile", "WPNotification");
        if (!string.IsNullOrEmpty(tileUri))
        {
            writer.WriteStartElement("wp", "BackgroundImage", "WPNotification");
            writer.WriteValue(tileUri);
            writer.WriteEndElement();
        }
        if (count.HasValue)
        {
            writer.WriteStartElement("wp", "Count", "WPNotification");
            writer.WriteValue(count.ToString());
            writer.WriteEndElement();
        }
        if (!string.IsNullOrEmpty(title))
        {
            writer.WriteStartElement("wp", "Title", "WPNotification");
            writer.WriteValue(title);
            writer.WriteEndElement();
        }
        writer.WriteEndElement();
        writer.Close();
        byte[] payload = stream.ToArray();

        //Check the length of the payload and reject it if too long
        if (payload.Length > MaxPayloadLength)
            throw new ArgumentOutOfRangeException(
                string.Format("Payload is too long. Maximum payload size shouldn't exceed {0} bytes",
                                MaxPayloadLength));

        //Create and initialize the request object
        var request = (HttpWebRequest) WebRequest.Create(notificationUrl);
        request.Method = "POST";
        request.ContentType = "text/xml; charset=utf-8";
        //request.ContentLength = payload.Length;
        request.Headers["X-MessageID"] = Guid.NewGuid().ToString();
        request.Headers["X-NotificationClass"] = 1.ToString();
        request.Headers["X-WindowsPhone-Target"] = "token";

        request.BeginGetRequestStream(
            ar =>
                {
                    //Once async call returns get the Stream object
                    Stream requestStream = request.EndGetRequestStream(ar);

                    //and start to write the payload to the stream asynchronously
                    requestStream.BeginWrite(
                        payload, 0, payload.Length,
                        iar =>
                            {
                                //When the writing is done, close the stream
                                requestStream.EndWrite(iar);
                                requestStream.Close();

                                //and switch to receiving the response from MPNS
                                request.BeginGetResponse(
                                    iarr =>
                                        {
                                            if (onComplete != null)
                                                onComplete();
                                        },
                                    null);
                            },
                        null);
                },
            null);
    }
}

Additional I also included my SettingsViewModel which is fully bindable.

[DataContract]
public class SettingsViewModel : ViewModelBase
{
    private ICommand _enforceTileUpdatesState;
    private bool _executing;
    private ICommand _setScheduleIfEnabled;
    private bool _tileUpdatesEnabled;

    [DataMember]
    public bool TileUpdatesEnabled
    {
        get { return _tileUpdatesEnabled; }
        set
        {
            if (value != _tileUpdatesEnabled)
            {
                _tileUpdatesEnabled = value;
                RaisePropertyChanged("TileUpdatesEnabled");
            }
        }
    }

    public bool Executing
    {
        get { return _executing; }
        set
        {
            if (value != _executing)
            {
                _executing = value;
                RaisePropertyChanged("Executing");
            }
        }
    }

    public ICommand SetScheduleIfEnabled
    {
        get
        {
            if (_setScheduleIfEnabled == null)
            {
                _setScheduleIfEnabled = new RelayCommand(
                    () =>
                        {
                            if (TileUpdatesEnabled)
                            {
                                Executing = true;
                                GetSchedule().CheckForStart();
                                Executing = false;
                            }
                        });
            }
            return _setScheduleIfEnabled;
        }
    }

    public ICommand EnforceTileUpdatesState
    {
        get
        {
            if (_enforceTileUpdatesState == null)
            {
                _enforceTileUpdatesState = new RelayCommand(
                    () =>
                        {
                            if (TileUpdatesEnabled)
                            {
                                Executing = true;
                                GetSchedule().Start(
                                    () =>
                                    Deployment.Current.Dispatcher.
                                        BeginInvoke(() => Executing = false));
                            }
                            else
                            {
                                Executing = true;
                                GetSchedule().Stop(
                                    () =>
                                    Deployment.Current.Dispatcher.
                                        BeginInvoke(() => Executing = false));
                            }
                        });
            }
            return _enforceTileUpdatesState;
        }
    }


    private SmartShellTileSchedule GetSchedule()
    {
        return new SmartShellTileSchedule
                    {
                        Interval = UpdateInterval.EveryHour,
                        RemoteImageUri =
                            new Uri(@"http://someserver/servertile.png"),
                        StartTime = DateTime.Now,
                        Recurrence = UpdateRecurrence.Interval
                    };
    }
}
Gravatar