Mobile Zone is brought to you in partnership with:

Den is a DZone Zone Leader and has posted 460 posts at DZone. You can read more from them at their website. View Full User Profile

Visual Studio Achievements for Windows Phone - Developing the core

11.27.2011
| 5278 views |
  • submit to reddit

As you probably heard (or read), the Channel9 guys are releasing a project called Visual Studio Achievements. If you've ever used an Xbox, you know that for some game elements the user is rewarded with an achievement - something that is accomplished along the storyline, but at the same time marks some sort of action that was done (or a set of actions). The idea of Visual Studio Achievements is the same - it doesn't require the developers to do anything special but continue their regular work. As they go, achievements will be awarded for specific milestones.

Going mobile

Once I saw the announcement, I thought that it would be great to have a mobile "Achievement Viewer", especially since there is an open API. A Windows Phone 7 app is a perfect start, so that's when I decided to start my weekend hackathon project - Visual Studio Achievements Mobile.

All data that is returned by the API is in JSON format. So I needed to decide whether I wanted to use System.Json or JSON.NET to read the necessary content. Take a look at what a simple response looks like:

{
   "Name":"visualstudio",
   "FriendlyName":"Visual Studio Achievements",
   "Description":"Earn achievements while you code! With Visual Studio Achievements, your code will be monitored and, as you proceed, you will unlock various achievements based on your activity. When you unlock an achievement, Visual Studio will let you know visually with a pop-up. In addition, your Channel 9 profile will be updated with any achievements you earn. See below for the list of achievements that are part of the plug-in.<br\/><br\/>  Note that the Visual Studio Achievements only works for C# and Visual Basic.  Progress based achievements can only be incremented once a minute. Achievements marked with <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a> only work on Visual Studio 2010 Premium or Ultimate unless FxCop is installed.  Visual Studio Achievements <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/Visual-Studio-Achievements-API">has an API<\/a> for those interested.",
   "Icon":"http:\/\/files.ch9.ms\/vsachievements\/VisualStudio_logo1.jpg",
   "InstallLink":"http:\/\/dev9.channel9.msdn.com\/blogs\/c9team\/Coming-Soon-Visual-Studio-Achievements",
   "ID":"21c3de10-d286-11e0-9572-0800200c9a66",
   "PromoDescription":"Earn achievements while you code!  With Visual Studio Achievements, your code will be monitored and, as you proceed, you will unlock various achievements based on your activity. ",
   "TwitterHashtag":"VSAchievements",
   "UserFriendlyName":"karstenj",
   "Achievements":[
      {
         "Name":"MoreThan10OverloadsAchievement",
         "FriendlyName":"Overload",
         "Description":"More than 10 overloads of a method. You could go with this or you could go with that. <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a>",
         "Points":"5",
         "Category":"Don't Try This At Home",
         "Icon":"http:\/\/files.ch9.ms\/vsachievements\/scissors.png",
         "IconSmall":"http:\/\/files.ch9.ms\/vsachievements\/scissors_sm.png",
         "DateEarned":"2011-11-22T14:14:30.1189969-08:00"
      },
      {
         "Name":"MoreThan20LongLocalAchievement",
         "FriendlyName":"Job Security",
         "Description":"Write 20 single letter class level variables in one file. Kudos to you for being cryptic! <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a>",
         "Category":"Don't Try This At Home",
         "Points":"0",
         "Icon":"http:\/\/files.ch9.ms\/vsachievements\/scissors.png",
         "IconSmall":"http:\/\/files.ch9.ms\/vsachievements\/scissors_sm.png",
         "DateEarned":"2011-11-22T14:14:30.1009959-08:00"
      },
      {
         "Name":"EqualOpportunistAchievement",
         "FriendlyName":"Equal Opportunist",
         "Description":"Write a class with public, private, protected and internal members. It's all about scope. <a href="http:\/\/channel9.msdn.com\/Blogs\/c9team\/FxCop-For-VS-Achievements">Uses FxCop<\/a>",
         "Category":"Power Coder",
         "Points":"10",
         "Icon":"http:\/\/files.ch9.ms\/vsachievements\/powercoder.png",
         "IconSmall":"http:\/\/files.ch9.ms\/vsachievements\/powercoder_sm.png",
         "DateEarned":"2011-11-22T14:14:30.0819948-08:00"
      }
   ],
   "TotalScore":20
}

(NOTE: I cut parts of the reponse to make it shorter for demo-purposes.)

Not a lot to parse or to deserialize, so JSON.NET would be an overkill. System.Json will do just fine here, so there is no need to use a third-party library. With this decision, I built the models for the data that I will be handling in the application. Each model is a separate class in the same project.

Achievement.cs

This is the main class that represents a single achievement entity. It is parsed out from the response associated with a user and will be used pretty much everywhere in the application.

public class Achievement
{
    public string Category { get; set; }
    public DateTime DateEarned { get; set; }
    public string Description { get; set; }
    public string FriendlyName { get; set; }
    public Uri Icon { get; set; }
    public Uri IconSmall { get; set; }
    public string Name { get; set; }
    public int Points { get; set; }
    public bool IsEarned { get; set; }
}

Niner.cs

This class represents a single Channel9 user that registered for Visual Studio Achievements. Some of the fields here are populated through a non-API way - direct HTML parsing simply because there are no public API endpoints for that.

public class Niner
{
    public string Alias { get; set; }
    public string Name { get; set; }
    public Uri Avatar { get; set; }
    public List<Achievement> Achievements { get; set; }
    public string Caption { get; set; }
    public int Points { get; set; }
}

Congratulations! These are the only two models that I will be using. The next part was deciding where to store the URL constants that will represent the API endpoints. I could either use an XML file or a static class with string constants. To avoid additional performance overhead with parsing XML data from inside the application package, the static class was a much better choice:

public static class URLConstants
{
    public const string NinerProfile = "http://channel9.msdn.com/niners/{0}";
    public const string AllAchievements = "http://channel9.msdn.com/niners/{0}/achievements/visualstudio?json=true";
    public const string UnlockedAchievements = "http://channel9.msdn.com/niners/{0}/achievements/visualstudio?json=true&raw=true";
}

Notice the filling indicators - those will be used to easily format the string to the necessary call.

Getting the Niner data

This is done through a single class - NinerReader.cs. It starts with a single reference to a Niner instance - CurrentNiner. This will be the final result that will be added to the collection of Niners associated with the device.

As you probably assumed, it would be a good idea to have multiple accounts syndicated on a single phone to provide such capabilities as achievement comparison and sharing.

private Niner CurrentNiner = new Niner();

Since I am not working with the Niner data in any other way but to get the registered achievements, I am adding the obtained data directly to the main collection. Here is how I am initiating the request:

public void AddNiner(string name, bool isInit)
{
string compositeUrl = string.Empty;

if (isInit)
{
CurrentNiner.Alias = name;
compositeUrl = string.Format(URLConstants.NinerProfile, name);
}
else
{
compositeUrl = string.Format(URLConstants.AllAchievements, name);
}

HttpWebRequest request = (HttpWebRequest)WebRequest.Create(compositeUrl);

if(isInit)
request.BeginGetResponse(new AsyncCallback(CompleteRequest), request);
else
request.BeginGetResponse(new AsyncCallback(CompleteAchievementRequest), request);
}

The Alias represents the nickname, so since I am already getting it through the initiating method, I might as well store it in the current Niner instance. The URL is composed based on the constant strings that I mentioned above. This is a universal HTTP request initiating method in NinerReader, so I am also passing a flag that determines whether this is the first request or not. If it is, I need to get the basic user data. If it is not, I need to get the achievement data associated with the passed user.

Here is the request callback:

private void CompleteRequest(IAsyncResult result)
{
    HttpWebRequest request = (HttpWebRequest)result.AsyncState;
    try
    {
        HttpWebResponse response = (HttpWebResponse)request.EndGetResponse(result);

        string HTMLContent;
        using (StreamReader reader = new StreamReader(response.GetResponseStream()))
        {
            HTMLContent = reader.ReadToEnd();
        }

        GetNinerContent(HTMLContent);
    }
    catch
    {
        App.MainPageDispatcher.BeginInvoke(new Action(() => MessageBox.Show("User not registered on Channel9.")));
    }
}

If the request fails and there is an active data connection, chances are that the user is not registered on Channel9, therefore it would make sense to display an informational message. The MainPageDispatcher is a Dispatcher instance defined in the App class and initialized on MainPage load. It is used for cross-thread interop between the data layer and the UI.

The first request is executed against the profile page, so I am reading the HTML data to get the URL for the user avatar, full name and profile caption. GetNinerContent does exactly that:

private void GetNinerContent(string HTMLContent)
{
    int location = HTMLContent.IndexOf("<div class=\"author\">");
    string stripped = HTMLContent.Substring(location, HTMLContent.Length - location);
    location = stripped.IndexOf("</div>");
    stripped = stripped.Substring(0, location+6);

    XDocument doc = XDocument.Parse(stripped);

    CurrentNiner.Name = (from c in doc.Root.Elements() where c.Name == "img" select c).FirstOrDefault().Attribute("alt").Value;

    string avatarUrl = (from c in doc.Root.Elements() where c.Name == "img" select c).FirstOrDefault().Attribute("src").Value;
    CurrentNiner.Avatar = new Uri(avatarUrl);

    string caption = (from c in doc.Root.Elements() where c.Name == "span" && c.Attribute("class").Value == "caption" select c).FirstOrDefault().Value;
    CurrentNiner.Caption = caption;

    AddNiner(CurrentNiner.Alias, false);
}

First, I am locating the author DIV and stripping the rest of the unnecessary content. Then, I am simply taking the necessary fragment and using it as an XML-formatted sequence for easier processing. Dirty hack, I know. It works for now, so I am more than happy to see it in the proof of concept.

When it is time to get the achievements, I am calling AddNiner with a false flag. I will get the data in this callback:

private void CompleteAchievementRequest(IAsyncResult result)
{
    HttpWebRequest request = (HttpWebRequest)result.AsyncState;
    try
    {
        HttpWebResponse response = (HttpWebResponse)request.EndGetResponse(result);

        string JSONContent;
        using (StreamReader reader = new StreamReader(response.GetResponseStream()))
        {
            JSONContent = reader.ReadToEnd();
        }

        CurrentNiner.Achievements = new List<Achievement>();

        System.Json.JsonValue obj = System.Json.JsonObject.Parse(JSONContent);
        JsonValue value = obj["Achievements"];

        foreach (JsonValue aValue in value)
        {
            Achievement achievement = new Achievement();
            achievement.Category = aValue["Category"];

            try
            {
                DateTime dateEarned = new DateTime();
                DateTime.TryParse(aValue["DateEarned"], out dateEarned);
                achievement.DateEarned = dateEarned;
                achievement.IsEarned = true;
            }
            catch (KeyNotFoundException ex)
            {
                achievement.IsEarned = false;
            }

            achievement.Description = aValue["Description"];
            achievement.FriendlyName = aValue["FriendlyName"];
            achievement.Icon = new Uri(aValue["Icon"]);
            achievement.IconSmall = new Uri(aValue["IconSmall"]);
            achievement.Name = aValue["Name"];
            string data = aValue["Points"];
            achievement.Points = Convert.ToInt32(data);

            CurrentNiner.Achievements.Add(achievement);

            if (achievement.IsEarned)
                CurrentNiner.Points += achievement.Points;
        }

        App.MainPageDispatcher.BeginInvoke(new Action(() => BindingPoint.Niners.Add(CurrentNiner)));
    }
    catch
    {
        App.MainPageDispatcher.BeginInvoke(new Action(() => MessageBox.Show("User not registered for achievements.")));
    }
}

This might seem to be a pretty extensive callback, but it is really basic. I am parsing the JSON content and making sure that all data is retrieved correctly. Notice the try...catch blocks. The one around the date processing part is there because specific achievements might not be available for a specific user - cases where those are not earned yet. The second block is there to catch the situation where the user is a member of Channel9 but is not registered for the Achievements program. 

As you can see, JSON is parsed through a simple foreach loop instead of initiating a deserialization process - that way I am saving some application resources, especially since the data is quite simplistic. Once the reading is finalized, the data is added to the Niner collection in the BindingPoint class:

public class BindingPoint
{
    public static ObservableCollection<Niner> Niners { get; set; }
}

This is the main core of the Visual Studio Achievements Mobile application. Stay tuned for updates on the application UI and additional Mango capabilities.