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
.
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
.
[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.
[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 string
s 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.
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.
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.
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.
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
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 QuestionData
s.
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:
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.
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:
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
.
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
.
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.
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:
- Check for the API connection using the
CheckApiConnection
method. - Fetch the access token using
GetAccessToken
method. - Fetch newest questions using the
FetchNewestQuestions
method. - Check whether there are newly posted questions. If yes, invoke the
NewQuestionTracked
event. - Wait a while. How long, is specified using the
millisecondsDelay
argument of the Start
method.
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]";
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"
string
s 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?
- The newest questions are fetched using
FetchNewestQuestions
. - A
for
loop iterates over all questions. - If the current question is not in the
postIds
queue, then:
- Add the ID of the current question to
postIds
. - 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. - 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. - We only store the 50 latest questions in the queue, so if there are more than 50, remove all extra items.
- If there are new questions tracked, invoke
_onNewQuestionTracked
on the dispatcher thread. - 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.
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:
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.
private void Application_Startup(object sender, StartupEventArgs e)
{
int _delayTime;
bool dtLoaded = Storage.LoadInt("delayTime", out _delayTime);
this.delayTime = dtLoaded ? _delayTime : 60000;
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.
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
:
<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:
<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 TextBlock
s and TextBox
es is 13 and their padding is 5px. The TextBlock
s 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:
<TabControl>
<TabItem Header="Recently posted questions">
</TabItem>
<TabItem Header="Settings">
</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:
<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
:
<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 DataGridTemplateColumn
s: 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 TextBlock
s, TextBox
es and Button
s. Without children, the TabItem
and the Grid
look like this:
<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
.
<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 TextBlock
s and TextBox
es come:
<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 TextBlock
s and TextBox
es, there are Button
s, to save the changed information or to take actions like restarting the tracker.
<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:
<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:
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
.
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.
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;
}
}
DelayTimeChanged
is invoked when the user changed the delay time. ClientIdSecretChanged
is invoked when the user changed the Client ID and Client Secret. TrackerRestartRequested
is invoked when the user clicked the button to restart the tracker. 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:
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.
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.
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.
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:
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).
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.
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.
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:
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.
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:
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
:
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
.
public bool CanExecute(object parameter)
{
return _actionToExecute != null;
}
CanExecuteChanged
is implemented like this:
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