Click here to Skip to main content
15,884,537 members
Articles / Programming Languages / C#

CodeProject New Questions Tracker - An Application to Keep Track of CodeProject's Recent Questions using the API

Rate me:
Please Sign up or sign in to vote.
5.00/5 (6 votes)
27 Sep 2015CPOL20 min read 18.9K   303   9   3
Application that displays a notification when a new question is posted on CodeProject
In this article, you will see an application that uses the CodeProject API to fetch the latest questions and show a notification balloon if there is a new one posted.

Introduction

The CodeProject New Questions Tracker is an application that uses the CodeProject API to fetch the latest questions and show a notification balloon if there is a new one posted. If you want to use the application, register a CodeProject API Client Id and Client Secret and enter it on the Settings tab of the main window of the New Questions Tracker. The application requires .NET 4.0 or higher.

Note: The CodeProject New Questions Tracker is not affiliated with The Code Project, I (ProgramFOX) created this tool and gave it the name "CodeProject New Questions Tracker" because it tracks new questions on CodeProject.

When a new question is tracked, you see a balloon appear in your notification area, and the new questions are added to a grid on the main window (see screenshot). The window is hidden by default; to show it, click on the notification area icon. Closing the window hides it, and does not shut down the application. Use "Exit tracker" on the Settings tab to shut it down.

Sometimes, the author is [unknown]. That's caused by this bug.

The New Questions Tracker uses the following dependencies:

Helper Classes

The tracker uses some helper classes, which contain frequently used methods.

Storage Class - For Storing Data in the AppData Folder

One of the helper classes is Storage. This class is used to store data in files and read data from files in your AppData folder, where the New Questions Tracker stores its data. It contains a StoreBytes method to store bytes in a file, a LoadBytes method to read bytes from a file, a StoreInt method to store an integer in a file and a LoadInt method to load an integer from a file.

The StoreBytes method has a string as parameter to indicate the filename and a byte array as parameter, which contains the bytes to be stored in a file. First, it checks whether the folder %AppData%\ProgramFOX\CodeProjectNewQuestionsTracker exists and if not, it creates the folder. Then, it uses File.WriteAllBytes to store the byte array in a file.

The LoadBytes method has a string as parameter to indicate the filename, and an out byte[] data parameter to write the file contents to. If the file exists, it reads the file, puts its content in data, and returns true. If the file does not exist, it returns false.

The StoreInt method takes a string and an int as parameter. The string indicates the file name to store the int, and the int is the data to store. The int gets converted to a byte array (using BitConverter) and that array is stored using StoreBytes.

The LoadInt method takes a string as parameter to indicate the filename, and out int data to write the file contents too. If the file exists, it reads its bytes using LoadBytes, converts those bytes to an int, sets the value of data to that int and returns true. If the file does not exist, it returns false.

C#
class Storage
{
    public static void StoreBytes(string filename, byte[] data)
    {
        string dir = Path.Combine(Environment.GetFolderPath
                     (Environment.SpecialFolder.ApplicationData), 
                     "ProgramFOX", "CodeProjectNewQuestionsTracker");
        if (!Directory.Exists(dir))
        {
            Directory.CreateDirectory(dir);
        }
        string fullPath = Path.Combine(dir, filename);
        File.WriteAllBytes(fullPath, data);
    }
    public static bool LoadBytes(string filename, out byte[] data)
    {
        string fullPath = Path.Combine(Environment.GetFolderPath
                          (Environment.SpecialFolder.ApplicationData), 
                          "ProgramFOX", "CodeProjectNewQuestionsTracker", filename);
        if (!File.Exists(fullPath))
        {
            data = null;
            return false;
        }
        data = File.ReadAllBytes(fullPath);
        return true;
    }
    public static void StoreInt(string filename, int data)
    {
        Storage.StoreBytes(filename, BitConverter.GetBytes(data));
    }
    public static bool LoadInt(string filename, out int data)
    {
        byte[] b;
        bool bytesLoaded = Storage.LoadBytes(filename, out b);
        data = bytesLoaded ? BitConverter.ToInt32(b, 0) : 0;
        return bytesLoaded;
    }
}

EncryptDecryptData - For Encryption and Decryption using CryptProtectData

CryptProtectData is a native wrapper for encryption and decryption. It encrypts/decrypts data using a key which is unique and can be obtained from the user profile. To use this wrapper, we need to use P/Invoke. The class contains two classes, DATA_BLOB and CRYPTPROTECT_PROMPTSTRUCT.

C#
[StructLayout(LayoutKind.Sequential)]
private class CRYPTPROTECT_PROMPTSTRUCT
{
    public int cbSize;
    public int dwPromptFlags;
    public IntPtr hwndApp;
    public String szPrompt;
}
[StructLayout(LayoutKind.Sequential)]
private class DATA_BLOB
{
    public int cbData;
    public IntPtr pbData;
}

These classes are used for the parameters of the CryptProtectData and CryptUnprotectData methods. These methods are used for the encryption and decryption.

C#
[DllImport("Crypt32.dll")]
private static extern bool CryptProtectData(
    DATA_BLOB pDataIn,
    String szDataDescr,
    DATA_BLOB pOptionalEntropy,
    IntPtr pvReserved,
    CRYPTPROTECT_PROMPTSTRUCT pPromptStruct,
    int dwFlags,
    DATA_BLOB pDataOut
);
[DllImport("Crypt32.dll")]
private static extern bool CryptUnprotectData(
    DATA_BLOB pDataIn,
    StringBuilder szDataDescr,
    DATA_BLOB pOptionalEntropy,
    IntPtr pvReserved,
    CRYPTPROTECT_PROMPTSTRUCT pPromptStruct,
    int dwFlags,
    DATA_BLOB pDataOut
);

These methods are not that easy and short to use, so there are two more methods in the class, EncryptData and DecryptData. These methods use the native methods which are defined in the above code block.

The EncryptData method has two strings as parameters, one containing the data, one containing a description. The method returns a Tuple<bool, byte[]> with a byte that indicates whether the operation succeeded and a byte array containing the encrypted data. To encrypt the data, EncryptData converts the data to a byte array, copies this array to an IntPtr and uses this IntPtr as parameter for the CryptProtectData method. This method returns a DATA_BLOB containing an int to indicate the length of the encrypted byte array and an IntPtr which holds the array. Then, the IntPtr data gets copied to a byte array and this array gets returned.

C#
public static Tuple<bool, byte[]> EncryptData(string data, string description)
{
    byte[] bytesData = Encoding.Default.GetBytes(data);
    int length = bytesData.Length;
    IntPtr pointer = Marshal.AllocHGlobal(length);
    Marshal.Copy(bytesData, 0, pointer, length);
    DATA_BLOB data_in = new DATA_BLOB();
    data_in.cbData = length;
    data_in.pbData = pointer;
    DATA_BLOB data_out = new DATA_BLOB();
    bool success = CryptProtectData
                   (data_in, description, null, IntPtr.Zero, null, 0, data_out);
    Marshal.FreeHGlobal(pointer);
    if (!success)
    {
        return new Tuple<bool, byte[]>(false, null);
    }
    byte[] outBytes = new byte[data_out.cbData];
    Marshal.Copy(data_out.pbData, outBytes, 0, data_out.cbData);
    return new Tuple<bool, byte[]>(true, outBytes);
}

The DecryptData method has a byte array as parameter, which holds the encrypted data. This method copies the byte array to an IntPtr and passes this as a parameter to the CryptUnprotectData method. CryptUnprotectData returns a DATA_BLOB containing an int to indicate the length of the decrypted data and an IntPtr which holds the data. Then, the IntPtr data gets copied to a byte array, this byte array gets converted to a string and that string gets returned.

C#
public static Tuple<bool, string> DecryptData(byte[] data)
{
    int length = data.Length;
    IntPtr pointer = Marshal.AllocHGlobal(length);
    Marshal.Copy(data, 0, pointer, length);
    DATA_BLOB data_in = new DATA_BLOB();
    data_in.cbData = length;
    data_in.pbData = pointer;
    DATA_BLOB data_out = new DATA_BLOB();
    StringBuilder description = new StringBuilder();
    bool success = CryptUnprotectData
                   (data_in, description, null, IntPtr.Zero, null, 0, data_out);
    Marshal.FreeHGlobal(pointer);
    if (!success)
    {
        return new Tuple<bool, string>(false, null);
    }
    byte[] outBytes = new byte[data_out.cbData];
    Marshal.Copy(data_out.pbData, outBytes, 0, data_out.cbData);
    string strData = Encoding.Default.GetString(outBytes);
    return new Tuple<bool, string>(true, strData);
}

ItemSummaryListViewModel, PaginationInfo, ItemSummary and NameIdPair - Classes for Holding Data Returned by the API

In the ResponseData.cs file, there are four classes: ItemSummaryListViewModel, PaginationInfo, ItemSummary and NameIdPair. There is not much to tell about these classes, they are made to hold the data returned by the CodeProject API.

C#
public class ItemSummaryListViewModel
{
    public PaginationInfo Pagination { get; set; }
    public ItemSummary[] Items { get; set; }
}
public class PaginationInfo
{
    public int Page { get; set; }
    public int PageSize { get; set; }
    public int TotalPages { get; set; }
    public int TotalItems { get; set; }
}
public class ItemSummary
{
    public string Id { get; set; }
    public string Title { get; set; }
    public NameIdPair[] Authors { get; set; }
    public string Summary { get; set; }
    public NameIdPair Doctype { get; set; }
    public NameIdPair[] Categories { get; set; }
    public NameIdPair[] Tags { get; set; }
    public NameIdPair License { get; set; }
    public string CreatedDate { get; set; }
    public string ModifiedDate { get; set; }
    public NameIdPair ThreadEditor { get; set; }
    public string ThreadModifiedDate { get; set; }
    public float Rating { get; set; }
    public int Votes { get; set; }
    public float Popularity { get; set; }
    public string WebsiteLink { get; set; }
    public string ApiLink { get; set; }
    public int ParentId { get; set; }
    public int ThreadId { get; set; }
    public int IndentLevel { get; set; }
}
public class NameIdPair
{
    public string Name { get; set; }
    public int Id { get; set; }
}

AccessTokenData - For Holding the Data when Fetching the Access Token

The helper class AccessTokenData is made to hold the data returned when the New Questions Tracker fetches the API access token.

C#
class AccessTokenData
{
    public string access_token { get; set; }
    public string token_type { get; set; }
    public int expires_in { get; set; }
}

QuestionData - A Class that Holds the Most Important Data of a Question

The QuestionData class holds the most important data of a question. It gets used when question data is passed to the user interface to be displayed. It has the following properties:

  • AuthorName - the name of the question author
  • AuthorUriStr - a string containing the URI of the question author
  • AuthorUri - the URI of the author, stored as an Uri
  • QuestionTitle - the title of the question
  • QuestionUriStr - a string containing the URI of the question
  • QuestionUri - the URI of the question, stored as an Uri
C#
public class QuestionData
{
    public string AuthorName { get; set; }
    public string AuthorUriStr { get; set; }
    public Uri AuthorUri
    {
        get
        {
            return new Uri(this.AuthorUriStr);
        }
    }
    public string QuestionTitle { get; set; }
    public string QuestionUriStr { get; set; }
    public Uri QuestionUri
    {
        get
        {
            return new Uri(this.QuestionUriStr);
        }
    }
}

NewQuestionTrackedEventHandler and NewQuestionTrackedEventArgs

The NewQuestionsTracker class which does the tracking work has an event NewQuestionTracked, which is of the type NewQuestionTrackedEventHandler, which accepts a NewQuestionTrackedEventArgs as argument. The NewQuestionTrackedEventArgs class contains an array of QuestionDatas.

C#
public delegate void NewQuestionTrackedEventHandler
       (object sender, NewQuestionTrackedEventArgs newQuestions);

public class NewQuestionTrackedEventArgs : EventArgs
{
    public QuestionData[] QuestionInformation { get; set; }
}

NewQuestionsTracker Class - The New Questions Tracker

Variable Declarations

At the top of the class, there are first some variables declared, which are used by the class:

C#
string accessToken = null;
ManualResetEvent cancelEvent = new ManualResetEvent(false);
bool running = false;
Queue<string> postIds = new Queue<string>();
Thread currThread;
  • accessToken holds the access token that's returned by the API.
  • cancelEvent is a ManualResetEvent that's used to cancel the tracking. Every time, after the tracker pulls new questions, it sleeps for a delay. Using cancelEvent, this sleeping can be aborted.
  • running is a boolean indicating whether the tracker is running or not.
  • postIds is a Queue that holds the IDs of the most recent questions. This queue is used to check whether a question is already posted or not, to see whether there are any new questions.
  • currThread holds the thread that currently runs the method to track new questions.

Events of NewQuestionsTracker

The NewQuestionsTracker class has several events, to send a notification when the API connection failed, the access token could not be fetched, or a new question is tracked.

  • ConnectionFailed is raised when the tracker could not make connection to the API.
  • AccessTokenNotFetched is raised when the API access token could not be fetched.
  • NewQuestionTracked is raised when new questions are tracked.
C#
Action _onConnectionFailed;
public event Action ConnectionFailed
{
    add
    {
        _onConnectionFailed += value;
    }
    remove
    {
        _onConnectionFailed -= value;
    }
}

Action _onAccessTokenNotFetched;
public event Action AccessTokenNotFetched
{
    add
    {
        _onAccessTokenNotFetched += value;
    }
    remove
    {
        _onAccessTokenNotFetched -= value;
    }
}

NewQuestionTrackedEventHandler _onNewQuestionTracked;
public event NewQuestionTrackedEventHandler NewQuestionTracked
{
    add
    {
        _onNewQuestionTracked += value;
    }
    remove
    {
        _onNewQuestionTracked -= value;
    }
}

Checking Connection to the API

When the tracker starts (see one of the next paragraphs for this procedure), the first thing it does is check whether there is connection to the API. It uses the CheckApiConnection method for that:

C#
bool CheckApiConnection()
{
    HttpWebRequest req = (HttpWebRequest)HttpWebRequest.Create("https://api.codeproject.com/");
    bool canConnect;
    try
    {
        using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
        {
            canConnect = resp != null && resp.StatusCode == HttpStatusCode.OK;
        }
    }
    catch (WebException)
    {
        canConnect = false;
    }
    return canConnect;
}

To check whether the tracker can connect to the API, it creates a HttpWebRequest that sends a request to https://api.codeproject.com/. If the status code is 200 OK, then it can connect fine. If the status code is not 200 OK or if WebException is thrown, it cannot connect.

Fetching the API Access Token

The second thing the tracker does is fetch an API access token. It passes the CodeProject API Client Id and Client Secret to the server, and the server returns an API access token, which grants you access to the API. The method decrypts the Client ID and Client Secret, passes them to the CodeProject API server and receives a JSON response which contains the access token. The method uses a HttpClient to interact with the server. The GetAccessToken method is part of the NewQuestionsTracker class, and the method returns a bool (to indicate succeeded/failed) and stores the access token in the property AccessToken.

C#
bool GetAccessToken(string clientIdFile, string clientSecretFile)
{
    byte[] clientIdEncrypted;
    bool gotClientIdEnc = Storage.LoadBytes(clientIdFile, out clientIdEncrypted);
    byte[] clientSecretEncrypted;
    bool gotClientSecretEnc = Storage.LoadBytes(clientSecretFile, out clientSecretEncrypted);
    if (!(gotClientIdEnc && gotClientSecretEnc))
    {
        return false;
    }
    Tuple<bool, string> clientIdDecrypted = EncryptDecryptData.DecryptData(clientIdEncrypted);
    Tuple<bool, string> clientSecretDecrypted = 
                        EncryptDecryptData.DecryptData(clientSecretEncrypted);
    if (!(clientIdDecrypted.Item1 && clientSecretDecrypted.Item1))
    {
        return false;
    }
    string clientId = clientIdDecrypted.Item2;
    string clientSecret = clientSecretDecrypted.Item2;
    clientIdDecrypted = null;
    clientSecretDecrypted = null;
    string json = null;
    string requestData = String.Format
                         ("grant_type=client_credentials&client_id={0}&client_secret={1}",
        Uri.EscapeDataString(clientId), Uri.EscapeDataString(clientSecret));
    using (WebClient client = new WebClient())
    {
        client.Headers[HttpRequestHeader.ContentType] = "application/x-www-form-urlencoded";
        try
        {
             json = client.UploadString("https://api.codeproject.com/Token", requestData);
         }
         catch (WebException)
         {
             return false;
         }
    }
    AccessTokenData access_token_data = JsonConvert.DeserializeObject<AccessTokenData>(json);
    this.accessToken = access_token_data.access_token;
    return true;
}

The above method first decrypts your Client ID and Client Secret. It uses the EncryptDecryptData class for that, whose explanation can be found in an earlier paragraph of this article. Then, it creates a WebClient to send a request to api.codeproject.com/Token, with the parameters grant_type, client_id and client_secret. Before sending this requests, it sets the Content-Type header to application/x-www-form-urlencoded. The WebClient.UploadString method returns JSON data, which gets deserialized using the JsonConvert.DeserializeObject method of JSON.NET.

Fetching the New Questions

The NewQuestionsTracker class has a FetchNewestQuestions method that fetches the most recent questions and returns an ItemSummaryListViewModel. It also used a WebClient, like GetAccessToken.

C#
ItemSummaryListViewModel FetchNewestQuestions()
{
    ItemSummaryListViewModel respData;
    using (WebClient client = new WebClient())
    {
        client.Headers[HttpRequestHeader.Accept] = "application/json";
        client.Headers[HttpRequestHeader.Pragma] = "no-cache";
        client.Headers[HttpRequestHeader.Authorization] = 
                                  String.Concat("Bearer ", this.accessToken);
        string json = client.DownloadString("https://api.codeproject.com/v1/Questions/new");
        respData = JsonConvert.DeserializeObject<ItemSummaryListViewModel>(json);
    }
    return respData;
}

After the WebClient is created, the headers are set. The Accept header is set to application/json to get a JSON response, Pragma is set to prevent caching from interfering with the request or the response, and the Authorization header is set to pass the access token. After setting the headers, the request is sent to https://api.codeproject.com/v1/Questions/new to get the newest questions.

The Start and Cancel Methods

The actual work is performed in the DoWork method (see next paragraph). The Start method creates a new thread for the DoWork method and runs that thread. The Cancel method uses cancelEvent (the ManualResetEvent) to cancel execution of the tracker.

C#
public void Start(int millisecondsDelay)
{
    running = true;
    cancelEvent.Reset();
    Thread thr = new Thread(DoWork);
    thr.IsBackground = true;
    currThread = thr;
    thr.Start(millisecondsDelay);
}
public void Cancel()
{
    running = false;
    cancelEvent.Set();
    currThread.Join();
}

The Start method also sets the state of cancelEvent to nonsignaled. The Cancel method uses the ManualResetEvent.Set() method to set the state of the event to "signaled". When the state of the event is signaled, this is handled in the DoWork method.

The DoWork Method

In this method, the actual work happens:

  1. Check for the API connection using the CheckApiConnection method.
  2. Fetch the access token using GetAccessToken method.
  3. Fetch newest questions using the FetchNewestQuestions method.
  4. Check whether there are newly posted questions. If yes, invoke the NewQuestionTracked event.
  5. Wait a while. How long, is specified using the millisecondsDelay argument of the Start method.
C#
void DoWork(object m)
{
    bool canConnect = CheckApiConnection();
    if (!canConnect)
    {
        if (_onConnectionFailed != null)
        {
            Application.Current.Dispatcher.BeginInvoke(_onConnectionFailed);
        }
        return;
    }
    int millisecondsDelay = (int)m;
    bool gotAccessToken = GetAccessToken("clientId", "clientSecret");
    if (!gotAccessToken)
    {
        if (_onAccessTokenNotFetched != null)
        {
            Application.Current.Dispatcher.BeginInvoke(_onAccessTokenNotFetched);
        }
        return;
    }
    if (!gotAccessToken)
    {
        if (_onAccessTokenNotFetched != null)
        {
            Application.Current.Dispatcher.BeginInvoke(_onAccessTokenNotFetched);
        }
        return;
    }
    while (running)
    {
        ItemSummaryListViewModel respData = FetchNewestQuestions();
        List<QuestionData> newQuestions = new List<QuestionData>();
        for (int i = 0; i < respData.Items.Length; i++)
        {
            ItemSummary item = respData.Items[i];
            if (!postIds.Contains(item.Id))
            {
                postIds.Enqueue(item.Id);
                QuestionData newQData = new QuestionData();
                if (item.WebsiteLink.StartsWith("//"))
                {
                    item.WebsiteLink = "http:" + item.WebsiteLink;
                }
                newQData.QuestionUriStr = item.WebsiteLink;
                newQData.QuestionTitle = item.Title;
                int authorId = item.Authors[0].Id;
                newQData.AuthorName = authorId != 0 ? item.Authors[0].Name : 
                "[unknown]"; // http://www.codeproject.com/Messages/4971799/
                             // Sometimes-author-name-is-empty-and-ID-is.aspx
                newQData.AuthorUriStr = String.Concat
                ("http://www.codeproject.com/script/Membership/View.aspx?mid=", authorId);
                newQuestions.Add(newQData);
            }
            else
            {
                break;
            }
        }
        if (postIds.Count > 50)
        {
            for (int i = postIds.Count; i > 50; i--)
            {
                postIds.Dequeue();
            }
        }
        if (newQuestions.Count > 0 && this._onNewQuestionTracked != null)
        {
            Application.Current.Dispatcher.BeginInvoke(this._onNewQuestionTracked,
                new object[] { this, new NewQuestionTrackedEventArgs 
                             { QuestionInformation = newQuestions.ToArray() } });
        }
        if (cancelEvent.WaitOne(millisecondsDelay))
        {
            break;
        }
    }
}

After calling CheckApiConnection, it checks its return value. If it's false, then it invokes _onConnectionFailed on the dispatcher thread. This is also the thread where the UI runs. If the tracker can connect to the API, it fetches the access token. The "clientId" and "clientSecret" strings specify in which AppData file the encrypted Client ID/Secret are stored. If the access token could not be fetched, then the method invokes _onAccessTokenNotFetched on the dispatcher thread.

Then, it enters a while loop. What happens inside this loop?

  1. The newest questions are fetched using FetchNewestQuestions.
  2. A for loop iterates over all questions.
  3. If the current question is not in the postIds queue, then:
    1. Add the ID of the current question to postIds.
    2. Create a QuestionData object with the information from the current ItemSummary. In case the ID of the question author is 0, then the name is empty, so replace it by [unknown]. The fact that the ID is sometimes 0, is caused by this bug.
    3. Add the newly created QuestionData to the newQuestions list.
    If the current question is already in the queue, then there won't come any new questions after that point, and we escape out of the for loop.
  4. We only store the 50 latest questions in the queue, so if there are more than 50, remove all extra items.
  5. If there are new questions tracked, invoke _onNewQuestionTracked on the dispatcher thread.
  6. Then, we wait the amount of specified milliseconds, using the cancelEvent.WaitOne method. When the Cancel method (thus also cancelEvent.Set) is called, WaitOne will get interrupted and return true. If it does, we break out of the while statement. If it does not, we continue.

The Application

Single-Instance Application

Because there should only be one instance of the New Questions Tracker running at a time, I made the application a single-instance application. I created a Program.cs file with a Main method that handles this. It tries to create a Mutex with the name CodeProjectNewQuestionsTracker and checks whether there is already an existing one. If there is, it shows an error message that there is already a running instance, and exits. At the end of the main method, the GC.KeepAlive method is called on the Mutex to make sure that it's not garbage collected. If it is, then another application can still be started because the mutex doesn't exist anymore.

Note: Because we create our own Main method, the Build Action of App.xaml should be set to Page instead of ApplicationDefinition. In Visual Studio, you can change the Build Action by clicking on App.xaml in the Solution Explorer and go to the Properties tab.

C#
class Program
{
    [STAThread]
    static void Main()
    {
        bool isNewInstance;
        Mutex singleInstanceMutex = 
              new Mutex(true, "CodeProjectNewQuestionsTracker", out isNewInstance);
        if (isNewInstance)
        {
            App applic = new App();
            applic.InitializeComponent();
            applic.Run();
        }
        else
        {
            MessageBox.Show("An instance is already running.", 
                            "Running instance", MessageBoxButton.OK, MessageBoxImage.Error);
            return;
        }
        GC.KeepAlive(singleInstanceMutex);
    }
}

The App class will be explained in the next paragraph.

The App Class

The App class starts up the tracker and the UI. At the first lines of this class (in App.xaml.cs), there are some variables declared:

C#
MainWindow _mainWindow = new MainWindow();
NewQuestionsTracker tracker = new NewQuestionsTracker();
int delayTime;

_mainWindow is the UI window (see next paragraph), tracker tracks the newest questions and delayTime holds the delay time in milliseconds.

The tracker is started up in the Application_Startup method. This method gets called after the Run method gets executed in the Main method.

C#
private void Application_Startup(object sender, StartupEventArgs e)
{
    int _delayTime;
    bool dtLoaded = Storage.LoadInt("delayTime", out _delayTime);
    this.delayTime = dtLoaded ? _delayTime : 60000; // default delay time: 60 seconds

    tracker.ConnectionFailed += this._mainWindow.tracker_ConnectionFailed;
    tracker.AccessTokenNotFetched += this._mainWindow.tracker_AccessTokenNotFetched;
    tracker.NewQuestionTracked += this._mainWindow.tracker_NewQuestionTracked;
    tracker.Start(delayTime);

    this.MainWindow = this._mainWindow;
    this._mainWindow.delayTime = delayTime;
    this._mainWindow.TrackerRestartRequested += RestartTracker;
    this._mainWindow.TrackingStartRequested += StartTracking;
    this._mainWindow.TrackingStopRequested += StopTracking;
    this._mainWindow.DelayTimeChanged += ChangeDelayTime;
    this._mainWindow.ClientIdSecretChanged += ChangeClientIdSecret;
}

First, it loads the delay time from the file. If that file doesn't exist, it sets the delay time to the default, 60 seconds. Then, it binds some of the NewQuestionsTracker events to methods of the MainWindow class, because they require UI interaction. It also binds some of the MainWindow events to methods of the App class.

The other methods in this class are called when a button on the UI is clicked:

  • RestartTracker calls Cancel and Start on the tracker to make it restart.
  • StartTracking calls Start on the tracker to start it.
  • StopTracking calls Cancel on the tracker to stop it.
  • ChangeDelayTime uses the Storage class to change the delay time.
  • ChangeClientIdSecret uses EncryptDecryptData and Storage to change the Client ID and Client Secret.
C#
private void RestartTracker(object sender, EventArgs e)
{
    tracker.Cancel();
    tracker.Start(delayTime);
}

private void StartTracking(object sender, EventArgs e)
{
    tracker.Start(delayTime);
}

private void StopTracking(object sender, EventArgs e)
{
    tracker.Cancel();
}

private void ChangeDelayTime(int newDelayTime)
{
    Storage.StoreInt("delayTime", newDelayTime);
}

private void ChangeClientIdSecret(string newClientId, string newClientSecret)
{
    byte[] cie = EncryptDecryptData.EncryptData
                 (newClientId, "CodeProject API Client ID").Item2;
    byte[] cse = EncryptDecryptData.EncryptData
                 (newClientSecret, "CodeProject API Client Secret").Item2;
    Storage.StoreBytes("clientId", cie);
    Storage.StoreBytes("clientSecret", cse);
}

The User Interface

The UI consists of a tab control with two pages: the page that shows the newest questions, and the page to change/display your settings. The questions page contains a data grid with rows to display the question title and the question poster as hyperlinks, and when clicking on them, the page will be opened in your default web browser.

MainWindow.xaml - The XAML Design

MainWindow.xaml contains the XAML code for the design of the form. The first lines create the Window:

XML
<Window x:Class="CodeProjectNewQuestionsTracker.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="CodeProject New Questions Tracker" Height="700" Width="950"
        xmlns:tb="http://www.hardcodet.net/taskbar"
        Closing="Window_Closing"
        WindowState="Maximized"
        Icon="/Icons/CodeProjectNewQuestionsTracker.ico">

The attributes of the root element set the title of the window to "CodeProject New Questions Tracker", maximize the window by default (and if it's not maximized, it's 950x700), sets the icon, binds Window_Closing (see MainWindow.xaml.cs, the next section) to the Closing event, and creates the tb namespace. This one is used for the Notification Area icon.

In the root element, there's a Window.Resources element. Here, it's only used to define some styles:

XML
<Window.Resources>
    <Style TargetType="TextBlock">
        <Setter Property="Padding" Value="5" />
        <Setter Property="FontSize" Value="13" />
    </Style>
    <Style TargetType="TextBlock" x:Key="dataGridTextBlockStyle" 
           BasedOn="{StaticResource {x:Type TextBlock}}" />
    <Style TargetType="TextBox">
        <Setter Property="Padding" Value="5" />
        <Setter Property="FontSize" Value="13" />
    </Style>
</Window.Resources>

The above Style elements make sure that the font size in the TextBlocks and TextBoxes is 13 and their padding is 5px. The TextBlocks in the data grid need a separate style rule with a key and when putting a TextBox in the data grid, you have to give it the dataGridTextBlockStyle style. If you don't, it doesn't get a different font size and padding, despite having a "global" style rule. I'm not sure why.

After the Window.Resources element, the TabControl comes. With the content of the pages hidden, it looks like this:

XML
<TabControl>
    <TabItem Header="Recently posted questions">
        <!-- DataGrid will go here -->
    </TabItem>
    <TabItem Header="Settings">
        <!-- Settings grid will go here -->
    </TabItem>
</TabControl>

The TabControl element contains TabItem elements which define the pages. These elements have a Header attribute, to set the header of the tab page.

Inside the first TabItem, the DataGrid goes. The data source of the grid is created in MainWindow.xaml.cs.

The DataGrid element without children looks like this:

C#
<DataGrid AutoGenerateColumns="False"
          CanUserReorderColumns="True"
          CanUserResizeColumns="True"
          CanUserAddRows="False"
          RowBackground="White"
          AlternatingRowBackground="LightYellow"
          IsReadOnly="True"
          x:Name="recentQsGrid">
    ...
</DataGrid>

The attributes ensure that columns cannot be auto-generated, the user can reorder and resize columns but not add columns, the row background is white and the alternating row background is light yellow (this means that there's one row white, another row yellow, then again white ...), and the data grid is readonly. Its name is recentQsGrid.

Then the columns have to be defined, inside the DataGrid tag. This can be done using DataGrid.Columns, DataGridTemplateColumn and DataTemplate:

XML
<DataGrid.Columns>
    <DataGridTemplateColumn Header="Post">
        <DataGridTemplateColumn.CellTemplate>
            <DataTemplate>
                <TextBlock Style="{StaticResource dataGridTextBlockStyle}">
                    <Hyperlink ToolTip="{Binding QuestionLink}"
                               NavigateUri="{Binding QuestionUri}"
                               RequestNavigate="hyperlink_RequestNavigate"
                               TextDecorations="None">
                        <Run Text="{Binding QuestionTitle}" />
                    </Hyperlink>
                </TextBlock>
            </DataTemplate>
        </DataGridTemplateColumn.CellTemplate>
    </DataGridTemplateColumn>
    <DataGridTemplateColumn Header="Author">
        <DataGridTemplateColumn.CellTemplate>
            <DataTemplate>
                <TextBlock Style="{StaticResource dataGridTextBlockStyle}">
                    <Hyperlink ToolTip="{Binding AuthorLink}"
                               NavigateUri="{Binding AuthorUri}"
                               RequestNavigate="hyperlink_RequestNavigate"
                               TextDecorations="None">
                        <Run Text="{Binding AuthorName}" />
                    </Hyperlink>
                </TextBlock>
            </DataTemplate>
        </DataGridTemplateColumn.CellTemplate>
    </DataGridTemplateColumn>
</DataGrid.Columns>

Inside DataGrid.Columns, we put two DataGridTemplateColumns: one for the post title, one for the post author. In the CellTemplate property of the DataTemplateColumn, we put a DataTemplate. This element holds the actual controls we put in our DataGrid. The template contains of a TextBlock with dataGridTextBlockStyle as style. This TextBlock holds a HyperLink that gets its tool tip and URI from the data source, which is set in the code-behind. The hyperlink does not have any text decorations and it executes hyperlink_RequestNavigate when the hyperlink is clicked. Inside the hyperlink, there's a Run, with the text bound to the question title or author name.

In the other tab page, we have a WPF Grid to display the TextBlocks, TextBoxes and Buttons. Without children, the TabItem and the Grid look like this:

XML
<TabItem>
    <Grid>
        ...
    </Grid>
</TabItem>

The first children of the Grid are the row definitions and the column definitions. There are two columns: one with "Auto" as Width and one that fills the rest of the row. There are 9 rows, all with "Auto" as Height.

XML
<Grid.ColumnDefinitions>
    <ColumnDefinition Width="Auto" />
    <ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
    <RowDefinition Height="Auto" />
</Grid.RowDefinitions>

After these definitions, some TextBlocks and TextBoxes come:

XML
<TextBlock Text="Client ID:" Grid.Column="0" Grid.Row="0" />
<TextBox Text="New Client ID here" Grid.Column="1" Grid.Row="0" x:Name="clientIdTxt" />
<TextBlock Text="Client Secret:" Grid.Column="0" Grid.Row="1" />
<TextBox Text="New Client Secret here" Grid.Column="1" Grid.Row="1" x:Name="clientSecretTxt" />
<TextBlock Text="Delay time (milliseconds, 1s = 1000ms):" Grid.Column="0" Grid.Row="2" />
<TextBox Grid.Column="1" Grid.Row="2"  x:Name="delayTimeTxt" Loaded="delayTimeTxt_Loaded" />
<TextBlock Text="Original Client ID and Client Secret 
                 are not exposed here for privacy/security reasons"
           Grid.ColumnSpan="2" Grid.Row="3" />

The first TextBlock says "Client ID:" and is placed on the left of the TextBox to fill in your Client ID. The second TextBlock says "Client Secret:" and is placed on the left of the TextBox to fill in your Client Secret. The third TextBlock gives some info about the delay time and is placed on the left of the TextBox to enter the delay time. Then, there's a TextBlock that says that the original Client ID and Client Secret are not exposed in the textboxes. They are kept secret for privacy/security reasons. You can only use the textboxes to enter a new one.

Under the TextBlocks and TextBoxes, there are Buttons, to save the changed information or to take actions like restarting the tracker.

XML
<Button Grid.Column="0" Grid.Row="4" x:Name="updateClientIdSecretBtn"
        Click="updateClientIdSecretBtn_Click">
    <TextBlock>
        Update Client ID and Client Secret
    </TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="5" x:Name="updateDelayBtn"
        Click="updateDelayBtn_Click">
    <TextBlock>
        Update delay time
    </TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="6" x:Name="stopStartTrackingBtn"
        Click="stopStartTrackingBtn_Click">
    <TextBlock>
        Stop tracking
    </TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="7" x:Name="restartTrackerBtn"
        Click="restartTrackerBtn_Click">
    <TextBlock>
        Restart tracker
    </TextBlock>
</Button>
<Button Grid.Column="0" Grid.Row="8" x:Name="exitTrackerBtn"
        Click="exitTrackerBtn_Click">
    <TextBlock>
        Exit tracker
    </TextBlock>
</Button>

All buttons have an inner TextBlock, to give them the padding and font size as specified in the Style. All Click events refer to methods in the code-behind.

The last element on the Settings tab is the notification area icon. It uses the WPF NotifyIcon library for this:

XML
<tb:TaskbarIcon x:Name="questionsTaskbarIcon"
        ToolTipText="CodeProject New Questions Tracker"
        IconSource="/Icons/CodeProjectNewQuestionsTracker.ico"
        Visibility="Visible" />

MainWindow.xaml.cs - The code-behind

MainWindow.xaml.cs contains the methods related to the UI: button clicks are handled, new questions are displayed on the grid, ...

First, there are some fields defined. These are used later in the class:

C#
bool shown = false;
bool tracking = true;
public int delayTime;
bool shouldExit = false;

Then, the RecentQuestions property is defined. This is an ObservableCollection and will be used to bind to the DataGrid.

C#
ObservableCollection<QuestionData> _recentQuestions = new ObservableCollection<QuestionData>();
ObservableCollection<QuestionData> RecentQuestions
{
    get
    {
        return _recentQuestions;
    }
    set
    {
        _recentQuestions = value;
    }
}

After that property, some events are defined. When one of the buttons is clicked, MainWindow will use these events to notify App, which will take appropriate action.

C#
Action<int> _delayTimeChanged;
public event Action<int> DelayTimeChanged
{
    add
    {
        _delayTimeChanged += value;
    }
    remove
    {
        _delayTimeChanged -= value;
    }
}

Action<string, string> _clientIdSecretChanged;
public event Action<string, string> ClientIdSecretChanged
{
    add
    {
        _clientIdSecretChanged += value;
    }
    remove
    {
        _clientIdSecretChanged -= value;
    }
}

EventHandler _trackerRestartRequested;
public event EventHandler TrackerRestartRequested
{
    add
    {
        _trackerRestartRequested += value;
    }
    remove
    {
        _trackerRestartRequested -= value;
    }
}

EventHandler _trackingStartRequested;
public event EventHandler TrackingStartRequested
{
    add
    {
        _trackingStartRequested += value;
    }
    remove
    {
        _trackingStartRequested -= value;
    }
}

EventHandler _trackingStopRequested;
public event EventHandler TrackingStopRequested
{
    add
    {
        _trackingStopRequested += value;
    }
    remove
    {
        _trackingStopRequested -= value;
    }
}
  1. DelayTimeChanged is invoked when the user changed the delay time.
  2. ClientIdSecretChanged is invoked when the user changed the Client ID and Client Secret.
  3. TrackerRestartRequested is invoked when the user clicked the button to restart the tracker.
  4. TrackingStartRequested is invoked when the user clicked the button to start the tracker. This is the same button as the button to stop the tracker, so it depends on the tracker status whether this event or TrackingStopRequested is invoked.

After these events, the constructor comes:

C#
public MainWindow()
{
    InitializeComponent();
    recentQsGrid.ItemsSource = RecentQuestions;
    ActionCommand leftClickCmd = new ActionCommand();
    leftClickCmd.ActionToExecute += leftClickCmd_ActionToExecute;
    questionsTaskbarIcon.LeftClickCommand = leftClickCmd;
}

The constructor sets the items source for the DataGrid. Then, it creates a new ActionCommand (which derives from ICommand, see the next section) and sets its ActionToExecute to leftClickCmd_ActionToExecute, a method we'll create later. Then it sets the LeftClickCommand of the notification area icon to the newly created ActionCommand. So, if you left-click on the icon in the notification area, leftClickCmd_ActionToExecute will be called.

After the constructor, leftClickCmd_ActionToExecute is created. If the window is shown, this method hides it. If the window is hidden, then the method shows it. It uses the shown variable to store whether the window is hidden or shown.

C#
void leftClickCmd_ActionToExecute(object obj)
{
    if (shown)
    {
        this.Hide();
    }
    else
    {
        this.Show();
    }
    shown = !shown;
}

Then the methods to handle tracker events come. These methods are bound to the tracker events in Application_Startup in App.xaml.cs.

C#
public void tracker_ConnectionFailed()
{
    MessageBox.Show("Could not connect to the CodeProject API. 
                     Please check your internet connection. 
                     If you have internet connection, check whether the API site is up.",
        "Could not connect",
        MessageBoxButton.OK,
        MessageBoxImage.Error);
}
public void tracker_AccessTokenNotFetched()
{
    MessageBox.Show("Access token could not be fetched. 
               Please re-enter your Client ID and Client Secret on the Settings tab.",
        "Could not fetch access token",
        MessageBoxButton.OK,
        MessageBoxImage.Error);
}
public void tracker_NewQuestionTracked(object sender, NewQuestionTrackedEventArgs e)
{
    QuestionData[] newQuestions = e.QuestionInformation;
    for (int i = newQuestions.Length - 1; i >= 0; i--)
    {
        RecentQuestions.Insert(0, newQuestions[i]);
    }
    if (newQuestions.Length > 1)
    {
        questionsTaskbarIcon.ShowBalloonTip("New questions", 
        String.Format("{0} new questions tracked.", newQuestions.Length), BalloonIcon.None);
    }
    else if (newQuestions.Length == 1)
    {
        questionsTaskbarIcon.ShowBalloonTip("New question", String.Format("{0} asked: {1}", 
            newQuestions[0].AuthorName, newQuestions[0].QuestionTitle), BalloonIcon.None);
    }
}

tracker_ConnectionFailed shows a message box, telling that the tracker could not connect to the API. tracker_AccessTokenNotFetched shows a message box, telling that the Access Token could not be fetched. tracker_NewQuestionTracked adds the new questions to the grid, and makes the notification area icon show a balloon. If there's more than one new question, it shows "N new questions tracked". If there's only one question, it shows "User asked: title".

Afterwards, the methods to handle the Window events come. The first one here is Window_Closing. This method hides the window, but does not close it, because that would shut down the app. It should only be closed when the "Exit Tracker" button is clicked.

C#
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
    this.Hide();
    shown = false;
    e.Cancel = !shouldExit;
}

shouldExit is false; when the user clicks on the "Exit tracker" button, it's set to true and then the application exits.

After the above method, we have the hyperlink_RequestNavigate method. When a hyperlink is clicked, this method uses Process.Start to open the page in your default browser:

C#
private void hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
    Process.Start(e.Uri.AbsoluteUri);
}

The next method is updateDelayBtn_Click. When the user updates the delay time, this method checks whether the entered time is valid, and then it invokes the DelayTimeChanged event to tell the App about this, and App will change the setting (see one of the previous sections).

C#
private void updateDelayBtn_Click(object sender, RoutedEventArgs e)
{
    int newDt;
    if (Int32.TryParse(delayTimeTxt.Text, out newDt))
    {
        delayTime = newDt;
        if (_delayTimeChanged != null)
        {
            _delayTimeChanged(newDt);
            MessageBox.Show("Delay time changed. The change will be applied 
                             when you restart the tracker.", "Delay time changed", 
                             MessageBoxButton.OK, MessageBoxImage.Information);
        }
    }
    else
    {
        MessageBox.Show("Entered delay time is not valid.", 
                        "Invalid delay time", MessageBoxButton.OK, MessageBoxImage.Error);
    }
}

The next method is delayTimeTxt_Loaded. When the textbox is loaded, this method puts the delay time in it.

C#
private void delayTimeTxt_Loaded(object sender, RoutedEventArgs e)
{
    delayTimeTxt.Text = delayTime.ToString();
}

The delayTime field is set from the App class.

Thereupon, we have the method to handle clicks on the "Stop tracking"/"Start tracking" button.

C#
private void stopStartTrackingBtn_Click(object sender, RoutedEventArgs e)
{
    if (tracking && _trackingStopRequested != null)
    {
        _trackingStopRequested(this, new EventArgs());
    }
    else if (!tracking && _trackingStartRequested != null)
    {
        _trackingStartRequested(this, new EventArgs());
    }
    stopStartTrackingBtn.Content = new TextBlock() 
                         { Text = tracking ? "Start tracking" : "Stop tracking" };
    tracking = !tracking;
}

If the application is tracking, it invokes the _trackingStopRequested event, else, it invokes the _trackingStartRequest event. Then it changes the text of the button, and it changes the value of the tracking field.

The button below the stop/start button, is the restart button. When clicking on it, it checks whether the tracker is running: if it's not, it gives a dialog box telling that you have to use the "Start tracking" button instead. If it is running, it invokes the _trackerRestartRequested event:

C#
private void restartTrackerBtn_Click(object sender, RoutedEventArgs e)
{
    if (!tracking)
    {
        MessageBox.Show("Cannot restart tracker because it is not running; 
                         click the 'Start tracking' button instead.");
    }
    else if (_trackerRestartRequested != null)
    {
        _trackerRestartRequested(this, new EventArgs());
    }
}

The last method in the MainWindow class is the method that handles a click on the "Exit tracker" button. If the tracker is running, it invokes the event to request to stop it. Then it sets shouldExit to true to avoid that the closing of the form gets cancelled, it disposes the notification area icon and it closes the window. Then the application will exit.

C#
private void exitTrackerBtn_Click(object sender, RoutedEventArgs e)
{
    if (tracking && _trackingStopRequested != null)
    {
        _trackingStopRequested(this, new EventArgs());
    }
    shouldExit = true;
    questionsTaskbarIcon.Dispose();
    this.Close();
}

The ActionCommand Class

I already mentioned this class in the previous section, at the constructor. It derives from ICommand, and we need this class because we have to pass an instance of it to the LeftClickCommand of the notification area icon.

A class that derives from ICommand requires two methods and one event: Execute, CanExecute and CanExecuteChanged (the latter is the event). Execute executes the command, CanExecute returns a boolean indicating whether the command can be executed or not, and CanExecuteChanged is invoked when the return value of CanExecute changes.

ActionCommand implements all of the above, but it also has one more event: ActionToExecute. This method is used by the main window; when Execute is called, ActionCommand invokes ActionToExecute.

ActionToExecute is implemented like this:

C#
Action<object> _actionToExecute;
public event Action<object> ActionToExecute
{
    add
    {
        bool canExecuteBefore = this.CanExecute(null);
        _actionToExecute += value;
        bool canExecuteAfter = this.CanExecute(null);
        if (canExecuteBefore != canExecuteAfter)
        {
            if (_canExecuteChanged != null)
            {
                _canExecuteChanged(this, new EventArgs());
            }
        }
    }
    remove
    {
        bool canExecuteBefore = this.CanExecute(null);
        _actionToExecute -= value;
        bool canExecuteAfter = this.CanExecute(null);
        if (canExecuteBefore != canExecuteAfter)
        {
            if (_canExecuteChanged != null)
            {
                _canExecuteChanged(this, new EventArgs());
            }
        }
    }
}

Before adding/removing an event handler to ActionToExecute, it stores the current value of CanExecute. Then it adds/removes the event handler to _actionToExecute. Thereupon, it checks the value of CanExecute again. If this is changed, _canExecuteChanged is invoked.

Execute first checks CanExecute, and if that method returns true, it invokes _actionToExecute:

C#
public void Execute(object parameter)
{
    if (this.CanExecute(parameter))
    {
        _actionToExecute(parameter);
    }
}

CanExecute checks whether _actionToExecute is not null: in that case, it returns true, else, false.

C#
public bool CanExecute(object parameter)
{
    return _actionToExecute != null;
}

CanExecuteChanged is implemented like this:

C#
EventHandler _canExecuteChanged;
public event EventHandler CanExecuteChanged
{
    add
    {
        _canExecuteChanged += value;
    }
    remove
    {
        _canExecuteChanged -= value;
    }
}

History

  • 27th September, 2015
    • Question links in the grid were broken because the API started returning protocol-relative URLs. This bug is now fixed and the links should work fine again.
  • 6th April, 2015
    • First version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Student
Europe Europe
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
GeneralIt's all good. Pin
jediYL15-Apr-15 12:54
professionaljediYL15-Apr-15 12:54 
GeneralRe: It's all good. Pin
Thomas Daniels15-Apr-15 19:42
mentorThomas Daniels15-Apr-15 19:42 
GeneralLooking forward to checking this out, later Pin
Slacker0076-Apr-15 6:43
professionalSlacker0076-Apr-15 6:43 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.